1mod project_panel_settings;
2mod utils;
3
4use anyhow::{Context as _, Result, anyhow};
5use client::{ErrorCode, ErrorExt};
6use collections::{BTreeSet, HashMap, hash_map};
7use command_palette_hooks::CommandPaletteFilter;
8use db::kvp::KEY_VALUE_STORE;
9use editor::{
10 Editor, EditorEvent, EditorSettings, ShowScrollbar,
11 items::{
12 entry_diagnostic_aware_icon_decoration_and_color,
13 entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
14 },
15 scroll::{Autoscroll, ScrollbarAutoHide},
16};
17use file_icons::FileIcons;
18use git::status::GitSummary;
19use gpui::{
20 Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
21 DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable,
22 Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
23 MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
24 Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
25 anchored, deferred, div, impl_actions, point, px, size, uniform_list,
26};
27use indexmap::IndexMap;
28use language::DiagnosticSeverity;
29use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
30use project::{
31 Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
32 ProjectPath, Worktree, WorktreeId,
33 git_store::{GitStoreEvent, git_traversal::ChildEntriesGitIter},
34 relativize_path,
35};
36use project_panel_settings::{
37 ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides,
38};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use settings::{Settings, SettingsStore, update_settings_file};
42use smallvec::SmallVec;
43use std::any::TypeId;
44use std::{
45 cell::OnceCell,
46 cmp,
47 collections::HashSet,
48 ffi::OsStr,
49 ops::Range,
50 path::{Path, PathBuf},
51 sync::Arc,
52 time::Duration,
53};
54use theme::ThemeSettings;
55use ui::{
56 Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
57 IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar,
58 ScrollbarState, Tooltip, prelude::*, v_flex,
59};
60use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths};
61use workspace::{
62 DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry,
63 Workspace,
64 dock::{DockPosition, Panel, PanelEvent},
65 notifications::{DetachAndPromptErr, NotifyTaskExt},
66};
67use worktree::CreatedEntry;
68
69const PROJECT_PANEL_KEY: &str = "ProjectPanel";
70const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
71
72pub struct ProjectPanel {
73 project: Entity<Project>,
74 fs: Arc<dyn Fs>,
75 focus_handle: FocusHandle,
76 scroll_handle: UniformListScrollHandle,
77 // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
78 // hovered over the start/end of a list.
79 hover_scroll_task: Option<Task<()>>,
80 visible_entries: Vec<(WorktreeId, Vec<GitEntry>, OnceCell<HashSet<Arc<Path>>>)>,
81 /// Maps from leaf project entry ID to the currently selected ancestor.
82 /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
83 /// project entries (and all non-leaf nodes are guaranteed to be directories).
84 ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
85 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
86 last_worktree_root_id: Option<ProjectEntryId>,
87 last_selection_drag_over_entry: Option<ProjectEntryId>,
88 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
89 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
90 unfolded_dir_ids: HashSet<ProjectEntryId>,
91 // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
92 selection: Option<SelectedEntry>,
93 marked_entries: BTreeSet<SelectedEntry>,
94 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
95 edit_state: Option<EditState>,
96 filename_editor: Entity<Editor>,
97 clipboard: Option<ClipboardEntry>,
98 _dragged_entry_destination: Option<Arc<Path>>,
99 workspace: WeakEntity<Workspace>,
100 width: Option<Pixels>,
101 pending_serialization: Task<Option<()>>,
102 show_scrollbar: bool,
103 vertical_scrollbar_state: ScrollbarState,
104 horizontal_scrollbar_state: ScrollbarState,
105 hide_scrollbar_task: Option<Task<()>>,
106 diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
107 max_width_item_index: Option<usize>,
108 // We keep track of the mouse down state on entries so we don't flash the UI
109 // in case a user clicks to open a file.
110 mouse_down: bool,
111 hover_expand_task: Option<Task<()>>,
112}
113
114#[derive(Copy, Clone, Debug)]
115struct FoldedDirectoryDragTarget {
116 entry_id: ProjectEntryId,
117 index: usize,
118 /// Whether we are dragging over the delimiter rather than the component itself.
119 is_delimiter_target: bool,
120}
121
122#[derive(Clone, Debug)]
123enum ValidationState {
124 None,
125 Warning(String),
126 Error(String),
127}
128
129#[derive(Clone, Debug)]
130struct EditState {
131 worktree_id: WorktreeId,
132 entry_id: ProjectEntryId,
133 leaf_entry_id: Option<ProjectEntryId>,
134 is_dir: bool,
135 depth: usize,
136 processing_filename: Option<String>,
137 previously_focused: Option<SelectedEntry>,
138 validation_state: ValidationState,
139}
140
141impl EditState {
142 fn is_new_entry(&self) -> bool {
143 self.leaf_entry_id.is_none()
144 }
145}
146
147#[derive(Clone, Debug)]
148enum ClipboardEntry {
149 Copied(BTreeSet<SelectedEntry>),
150 Cut(BTreeSet<SelectedEntry>),
151}
152
153#[derive(Debug, PartialEq, Eq, Clone)]
154struct EntryDetails {
155 filename: String,
156 icon: Option<SharedString>,
157 path: Arc<Path>,
158 depth: usize,
159 kind: EntryKind,
160 is_ignored: bool,
161 is_expanded: bool,
162 is_selected: bool,
163 is_marked: bool,
164 is_editing: bool,
165 is_processing: bool,
166 is_cut: bool,
167 filename_text_color: Color,
168 diagnostic_severity: Option<DiagnosticSeverity>,
169 git_status: GitSummary,
170 is_private: bool,
171 worktree_id: WorktreeId,
172 canonical_path: Option<Arc<Path>>,
173}
174
175#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
176#[serde(deny_unknown_fields)]
177struct Delete {
178 #[serde(default)]
179 pub skip_prompt: bool,
180}
181
182#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
183#[serde(deny_unknown_fields)]
184struct Trash {
185 #[serde(default)]
186 pub skip_prompt: bool,
187}
188
189impl_actions!(project_panel, [Delete, Trash]);
190
191actions!(
192 project_panel,
193 [
194 ExpandSelectedEntry,
195 CollapseSelectedEntry,
196 CollapseAllEntries,
197 NewDirectory,
198 NewFile,
199 Copy,
200 Duplicate,
201 RevealInFileManager,
202 RemoveFromProject,
203 OpenWithSystem,
204 Cut,
205 Paste,
206 Rename,
207 Open,
208 OpenPermanent,
209 ToggleFocus,
210 ToggleHideGitIgnore,
211 NewSearchInDirectory,
212 UnfoldDirectory,
213 FoldDirectory,
214 SelectParent,
215 SelectNextGitEntry,
216 SelectPrevGitEntry,
217 SelectNextDiagnostic,
218 SelectPrevDiagnostic,
219 SelectNextDirectory,
220 SelectPrevDirectory,
221 ]
222);
223
224#[derive(Debug, Default)]
225struct FoldedAncestors {
226 current_ancestor_depth: usize,
227 ancestors: Vec<ProjectEntryId>,
228}
229
230impl FoldedAncestors {
231 fn max_ancestor_depth(&self) -> usize {
232 self.ancestors.len()
233 }
234}
235
236pub fn init_settings(cx: &mut App) {
237 ProjectPanelSettings::register(cx);
238}
239
240pub fn init(cx: &mut App) {
241 init_settings(cx);
242
243 cx.observe_new(|workspace: &mut Workspace, _, _| {
244 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
245 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
246 });
247
248 workspace.register_action(|workspace, _: &ToggleHideGitIgnore, _, cx| {
249 let fs = workspace.app_state().fs.clone();
250 update_settings_file::<ProjectPanelSettings>(fs, cx, move |setting, _| {
251 setting.hide_gitignore = Some(!setting.hide_gitignore.unwrap_or(false));
252 })
253 });
254 })
255 .detach();
256}
257
258#[derive(Debug)]
259pub enum Event {
260 OpenedEntry {
261 entry_id: ProjectEntryId,
262 focus_opened_item: bool,
263 allow_preview: bool,
264 },
265 SplitEntry {
266 entry_id: ProjectEntryId,
267 },
268 Focus,
269}
270
271#[derive(Serialize, Deserialize)]
272struct SerializedProjectPanel {
273 width: Option<Pixels>,
274}
275
276struct DraggedProjectEntryView {
277 selection: SelectedEntry,
278 details: EntryDetails,
279 click_offset: Point<Pixels>,
280 selections: Arc<BTreeSet<SelectedEntry>>,
281}
282
283struct ItemColors {
284 default: Hsla,
285 hover: Hsla,
286 drag_over: Hsla,
287 marked: Hsla,
288 focused: Hsla,
289}
290
291fn get_item_color(cx: &App) -> ItemColors {
292 let colors = cx.theme().colors();
293
294 ItemColors {
295 default: colors.panel_background,
296 hover: colors.element_hover,
297 marked: colors.element_selected,
298 focused: colors.panel_focused_border,
299 drag_over: colors.drop_target_background,
300 }
301}
302
303impl ProjectPanel {
304 fn new(
305 workspace: &mut Workspace,
306 window: &mut Window,
307 cx: &mut Context<Workspace>,
308 ) -> Entity<Self> {
309 let project = workspace.project().clone();
310 let git_store = project.read(cx).git_store().clone();
311 let project_panel = cx.new(|cx| {
312 let focus_handle = cx.focus_handle();
313 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
314 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
315 this.focus_out(window, cx);
316 this.hide_scrollbar(window, cx);
317 })
318 .detach();
319
320 cx.subscribe(&git_store, |this, _, event, cx| match event {
321 GitStoreEvent::RepositoryUpdated(_, _, _)
322 | GitStoreEvent::RepositoryAdded(_)
323 | GitStoreEvent::RepositoryRemoved(_) => {
324 this.update_visible_entries(None, cx);
325 cx.notify();
326 }
327 _ => {}
328 })
329 .detach();
330
331 cx.subscribe(&project, |this, project, event, cx| match event {
332 project::Event::ActiveEntryChanged(Some(entry_id)) => {
333 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
334 this.reveal_entry(project.clone(), *entry_id, true, cx).ok();
335 }
336 }
337 project::Event::ActiveEntryChanged(None) => {
338 this.marked_entries.clear();
339 }
340 project::Event::RevealInProjectPanel(entry_id) => {
341 if let Some(()) = this
342 .reveal_entry(project.clone(), *entry_id, false, cx)
343 .log_err()
344 {
345 cx.emit(PanelEvent::Activate);
346 }
347 }
348 project::Event::ActivateProjectPanel => {
349 cx.emit(PanelEvent::Activate);
350 }
351 project::Event::DiskBasedDiagnosticsFinished { .. }
352 | project::Event::DiagnosticsUpdated { .. } => {
353 if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off
354 {
355 this.update_diagnostics(cx);
356 cx.notify();
357 }
358 }
359 project::Event::WorktreeRemoved(id) => {
360 this.expanded_dir_ids.remove(id);
361 this.update_visible_entries(None, cx);
362 cx.notify();
363 }
364 project::Event::WorktreeUpdatedEntries(_, _)
365 | project::Event::WorktreeAdded(_)
366 | project::Event::WorktreeOrderChanged => {
367 this.update_visible_entries(None, cx);
368 cx.notify();
369 }
370 project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
371 if let Some((worktree, expanded_dir_ids)) = project
372 .read(cx)
373 .worktree_for_id(*worktree_id, cx)
374 .zip(this.expanded_dir_ids.get_mut(&worktree_id))
375 {
376 let worktree = worktree.read(cx);
377
378 let Some(entry) = worktree.entry_for_id(*entry_id) else {
379 return;
380 };
381 let include_ignored_dirs = !entry.is_ignored;
382
383 let mut dirs_to_expand = vec![*entry_id];
384 while let Some(current_id) = dirs_to_expand.pop() {
385 let Some(current_entry) = worktree.entry_for_id(current_id) else {
386 continue;
387 };
388 for child in worktree.child_entries(¤t_entry.path) {
389 if !child.is_dir() || (include_ignored_dirs && child.is_ignored) {
390 continue;
391 }
392
393 dirs_to_expand.push(child.id);
394
395 if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
396 expanded_dir_ids.insert(ix, child.id);
397 }
398 this.unfolded_dir_ids.insert(child.id);
399 }
400 }
401 this.update_visible_entries(None, cx);
402 cx.notify();
403 }
404 }
405 _ => {}
406 })
407 .detach();
408
409 let trash_action = [TypeId::of::<Trash>()];
410 let is_remote = project.read(cx).is_via_collab();
411
412 if is_remote {
413 CommandPaletteFilter::update_global(cx, |filter, _cx| {
414 filter.hide_action_types(&trash_action);
415 });
416 }
417
418 let filename_editor = cx.new(|cx| Editor::single_line(window, cx));
419
420 cx.subscribe(
421 &filename_editor,
422 |project_panel, _, editor_event, cx| match editor_event {
423 EditorEvent::BufferEdited => {
424 project_panel.populate_validation_error(cx);
425 project_panel.autoscroll(cx);
426 }
427 EditorEvent::SelectionsChanged { .. } => {
428 project_panel.autoscroll(cx);
429 }
430 EditorEvent::Blurred => {
431 if project_panel
432 .edit_state
433 .as_ref()
434 .map_or(false, |state| state.processing_filename.is_none())
435 {
436 project_panel.edit_state = None;
437 project_panel.update_visible_entries(None, cx);
438 cx.notify();
439 }
440 }
441 _ => {}
442 },
443 )
444 .detach();
445
446 cx.observe_global::<FileIcons>(|_, cx| {
447 cx.notify();
448 })
449 .detach();
450
451 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
452 cx.observe_global::<SettingsStore>(move |this, cx| {
453 let new_settings = *ProjectPanelSettings::get_global(cx);
454 if project_panel_settings != new_settings {
455 if project_panel_settings.hide_gitignore != new_settings.hide_gitignore {
456 this.update_visible_entries(None, cx);
457 }
458 project_panel_settings = new_settings;
459 this.update_diagnostics(cx);
460 cx.notify();
461 }
462 })
463 .detach();
464
465 let scroll_handle = UniformListScrollHandle::new();
466 let mut this = Self {
467 project: project.clone(),
468 hover_scroll_task: None,
469 fs: workspace.app_state().fs.clone(),
470 focus_handle,
471 visible_entries: Default::default(),
472 ancestors: Default::default(),
473 folded_directory_drag_target: None,
474 last_worktree_root_id: Default::default(),
475 last_external_paths_drag_over_entry: None,
476 last_selection_drag_over_entry: None,
477 expanded_dir_ids: Default::default(),
478 unfolded_dir_ids: Default::default(),
479 selection: None,
480 marked_entries: Default::default(),
481 edit_state: None,
482 context_menu: None,
483 filename_editor,
484 clipboard: None,
485 _dragged_entry_destination: None,
486 workspace: workspace.weak_handle(),
487 width: None,
488 pending_serialization: Task::ready(None),
489 show_scrollbar: !Self::should_autohide_scrollbar(cx),
490 hide_scrollbar_task: None,
491 vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
492 .parent_entity(&cx.entity()),
493 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
494 .parent_entity(&cx.entity()),
495 max_width_item_index: None,
496 diagnostics: Default::default(),
497 scroll_handle,
498 mouse_down: false,
499 hover_expand_task: None,
500 };
501 this.update_visible_entries(None, cx);
502
503 this
504 });
505
506 cx.subscribe_in(&project_panel, window, {
507 let project_panel = project_panel.downgrade();
508 move |workspace, _, event, window, cx| match event {
509 &Event::OpenedEntry {
510 entry_id,
511 focus_opened_item,
512 allow_preview,
513 } => {
514 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
515 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
516 let file_path = entry.path.clone();
517 let worktree_id = worktree.read(cx).id();
518 let entry_id = entry.id;
519 let is_via_ssh = project.read(cx).is_via_ssh();
520
521 workspace
522 .open_path_preview(
523 ProjectPath {
524 worktree_id,
525 path: file_path.clone(),
526 },
527 None,
528 focus_opened_item,
529 allow_preview,
530 true,
531 window, cx,
532 )
533 .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {
534 match e.error_code() {
535 ErrorCode::Disconnected => if is_via_ssh {
536 Some("Disconnected from SSH host".to_string())
537 } else {
538 Some("Disconnected from remote project".to_string())
539 },
540 ErrorCode::UnsharedItem => Some(format!(
541 "{} is not shared by the host. This could be because it has been marked as `private`",
542 file_path.display()
543 )),
544 // See note in worktree.rs where this error originates. Returning Some in this case prevents
545 // the error popup from saying "Try Again", which is a red herring in this case
546 ErrorCode::Internal if e.to_string().contains("File is too large to load") => Some(e.to_string()),
547 _ => None,
548 }
549 });
550
551 if let Some(project_panel) = project_panel.upgrade() {
552 // Always select and mark the entry, regardless of whether it is opened or not.
553 project_panel.update(cx, |project_panel, _| {
554 let entry = SelectedEntry { worktree_id, entry_id };
555 project_panel.marked_entries.clear();
556 project_panel.marked_entries.insert(entry);
557 project_panel.selection = Some(entry);
558 });
559 if !focus_opened_item {
560 let focus_handle = project_panel.read(cx).focus_handle.clone();
561 window.focus(&focus_handle);
562 }
563 }
564 }
565 }
566 }
567 &Event::SplitEntry { entry_id } => {
568 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
569 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
570 workspace
571 .split_path(
572 ProjectPath {
573 worktree_id: worktree.read(cx).id(),
574 path: entry.path.clone(),
575 },
576 window, cx,
577 )
578 .detach_and_log_err(cx);
579 }
580 }
581 }
582
583 _ => {}
584 }
585 })
586 .detach();
587
588 project_panel
589 }
590
591 pub async fn load(
592 workspace: WeakEntity<Workspace>,
593 mut cx: AsyncWindowContext,
594 ) -> Result<Entity<Self>> {
595 let serialized_panel = cx
596 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
597 .await
598 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
599 .log_err()
600 .flatten()
601 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
602 .transpose()
603 .log_err()
604 .flatten();
605
606 workspace.update_in(&mut cx, |workspace, window, cx| {
607 let panel = ProjectPanel::new(workspace, window, cx);
608 if let Some(serialized_panel) = serialized_panel {
609 panel.update(cx, |panel, cx| {
610 panel.width = serialized_panel.width.map(|px| px.round());
611 cx.notify();
612 });
613 }
614 panel
615 })
616 }
617
618 fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
619 let mut diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity> =
620 Default::default();
621 let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
622
623 if show_diagnostics_setting != ShowDiagnostics::Off {
624 self.project
625 .read(cx)
626 .diagnostic_summaries(false, cx)
627 .filter_map(|(path, _, diagnostic_summary)| {
628 if diagnostic_summary.error_count > 0 {
629 Some((path, DiagnosticSeverity::ERROR))
630 } else if show_diagnostics_setting == ShowDiagnostics::All
631 && diagnostic_summary.warning_count > 0
632 {
633 Some((path, DiagnosticSeverity::WARNING))
634 } else {
635 None
636 }
637 })
638 .for_each(|(project_path, diagnostic_severity)| {
639 let mut path_buffer = PathBuf::new();
640 Self::update_strongest_diagnostic_severity(
641 &mut diagnostics,
642 &project_path,
643 path_buffer.clone(),
644 diagnostic_severity,
645 );
646
647 for component in project_path.path.components() {
648 path_buffer.push(component);
649 Self::update_strongest_diagnostic_severity(
650 &mut diagnostics,
651 &project_path,
652 path_buffer.clone(),
653 diagnostic_severity,
654 );
655 }
656 });
657 }
658 self.diagnostics = diagnostics;
659 }
660
661 fn update_strongest_diagnostic_severity(
662 diagnostics: &mut HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
663 project_path: &ProjectPath,
664 path_buffer: PathBuf,
665 diagnostic_severity: DiagnosticSeverity,
666 ) {
667 diagnostics
668 .entry((project_path.worktree_id, path_buffer.clone()))
669 .and_modify(|strongest_diagnostic_severity| {
670 *strongest_diagnostic_severity =
671 cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
672 })
673 .or_insert(diagnostic_severity);
674 }
675
676 fn serialize(&mut self, cx: &mut Context<Self>) {
677 let width = self.width;
678 self.pending_serialization = cx.background_spawn(
679 async move {
680 KEY_VALUE_STORE
681 .write_kvp(
682 PROJECT_PANEL_KEY.into(),
683 serde_json::to_string(&SerializedProjectPanel { width })?,
684 )
685 .await?;
686 anyhow::Ok(())
687 }
688 .log_err(),
689 );
690 }
691
692 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
693 if !self.focus_handle.contains_focused(window, cx) {
694 cx.emit(Event::Focus);
695 }
696 }
697
698 fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
699 if !self.focus_handle.is_focused(window) {
700 self.confirm(&Confirm, window, cx);
701 }
702 }
703
704 fn deploy_context_menu(
705 &mut self,
706 position: Point<Pixels>,
707 entry_id: ProjectEntryId,
708 window: &mut Window,
709 cx: &mut Context<Self>,
710 ) {
711 let project = self.project.read(cx);
712
713 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
714 id
715 } else {
716 return;
717 };
718
719 self.selection = Some(SelectedEntry {
720 worktree_id,
721 entry_id,
722 });
723
724 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
725 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
726 let worktree = worktree.read(cx);
727 let is_root = Some(entry) == worktree.root_entry();
728 let is_dir = entry.is_dir();
729 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
730 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
731 let is_read_only = project.is_read_only(cx);
732 let is_remote = project.is_via_collab();
733 let is_local = project.is_local();
734
735 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
736 menu.context(self.focus_handle.clone()).map(|menu| {
737 if is_read_only {
738 menu.when(is_dir, |menu| {
739 menu.action("Search Inside", Box::new(NewSearchInDirectory))
740 })
741 } else {
742 menu.action("New File", Box::new(NewFile))
743 .action("New Folder", Box::new(NewDirectory))
744 .separator()
745 .when(is_local && cfg!(target_os = "macos"), |menu| {
746 menu.action("Reveal in Finder", Box::new(RevealInFileManager))
747 })
748 .when(is_local && cfg!(not(target_os = "macos")), |menu| {
749 menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
750 })
751 .when(is_local, |menu| {
752 menu.action("Open in Default App", Box::new(OpenWithSystem))
753 })
754 .action("Open in Terminal", Box::new(OpenInTerminal))
755 .when(is_dir, |menu| {
756 menu.separator()
757 .action("Find in Folder…", Box::new(NewSearchInDirectory))
758 })
759 .when(is_unfoldable, |menu| {
760 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
761 })
762 .when(is_foldable, |menu| {
763 menu.action("Fold Directory", Box::new(FoldDirectory))
764 })
765 .separator()
766 .action("Cut", Box::new(Cut))
767 .action("Copy", Box::new(Copy))
768 .action("Duplicate", Box::new(Duplicate))
769 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
770 .map(|menu| {
771 if self.clipboard.as_ref().is_some() {
772 menu.action("Paste", Box::new(Paste))
773 } else {
774 menu.disabled_action("Paste", Box::new(Paste))
775 }
776 })
777 .separator()
778 .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
779 .action(
780 "Copy Relative Path",
781 Box::new(zed_actions::workspace::CopyRelativePath),
782 )
783 .separator()
784 .when(!is_root || !cfg!(target_os = "windows"), |menu| {
785 menu.action("Rename", Box::new(Rename))
786 })
787 .when(!is_root & !is_remote, |menu| {
788 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
789 })
790 .when(!is_root, |menu| {
791 menu.action("Delete", Box::new(Delete { skip_prompt: false }))
792 })
793 .when(!is_remote & is_root, |menu| {
794 menu.separator()
795 .action(
796 "Add Folder to Project…",
797 Box::new(workspace::AddFolderToProject),
798 )
799 .action("Remove from Project", Box::new(RemoveFromProject))
800 })
801 .when(is_root, |menu| {
802 menu.separator()
803 .action("Collapse All", Box::new(CollapseAllEntries))
804 })
805 }
806 })
807 });
808
809 window.focus(&context_menu.focus_handle(cx));
810 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
811 this.context_menu.take();
812 cx.notify();
813 });
814 self.context_menu = Some((context_menu, position, subscription));
815 }
816
817 cx.notify();
818 }
819
820 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
821 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
822 return false;
823 }
824
825 if let Some(parent_path) = entry.path.parent() {
826 let snapshot = worktree.snapshot();
827 let mut child_entries = snapshot.child_entries(parent_path);
828 if let Some(child) = child_entries.next() {
829 if child_entries.next().is_none() {
830 return child.kind.is_dir();
831 }
832 }
833 };
834 false
835 }
836
837 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
838 if entry.is_dir() {
839 let snapshot = worktree.snapshot();
840
841 let mut child_entries = snapshot.child_entries(&entry.path);
842 if let Some(child) = child_entries.next() {
843 if child_entries.next().is_none() {
844 return child.kind.is_dir();
845 }
846 }
847 }
848 false
849 }
850
851 fn expand_selected_entry(
852 &mut self,
853 _: &ExpandSelectedEntry,
854 window: &mut Window,
855 cx: &mut Context<Self>,
856 ) {
857 if let Some((worktree, entry)) = self.selected_entry(cx) {
858 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
859 if folded_ancestors.current_ancestor_depth > 0 {
860 folded_ancestors.current_ancestor_depth -= 1;
861 cx.notify();
862 return;
863 }
864 }
865 if entry.is_dir() {
866 let worktree_id = worktree.id();
867 let entry_id = entry.id;
868 let expanded_dir_ids =
869 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
870 expanded_dir_ids
871 } else {
872 return;
873 };
874
875 match expanded_dir_ids.binary_search(&entry_id) {
876 Ok(_) => self.select_next(&SelectNext, window, cx),
877 Err(ix) => {
878 self.project.update(cx, |project, cx| {
879 project.expand_entry(worktree_id, entry_id, cx);
880 });
881
882 expanded_dir_ids.insert(ix, entry_id);
883 self.update_visible_entries(None, cx);
884 cx.notify();
885 }
886 }
887 }
888 }
889 }
890
891 fn collapse_selected_entry(
892 &mut self,
893 _: &CollapseSelectedEntry,
894 _: &mut Window,
895 cx: &mut Context<Self>,
896 ) {
897 let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
898 return;
899 };
900 self.collapse_entry(entry.clone(), worktree, cx)
901 }
902
903 fn collapse_entry(&mut self, entry: Entry, worktree: Entity<Worktree>, cx: &mut Context<Self>) {
904 let worktree = worktree.read(cx);
905 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
906 if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
907 folded_ancestors.current_ancestor_depth += 1;
908 cx.notify();
909 return;
910 }
911 }
912 let worktree_id = worktree.id();
913 let expanded_dir_ids =
914 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
915 expanded_dir_ids
916 } else {
917 return;
918 };
919
920 let mut entry = &entry;
921 loop {
922 let entry_id = entry.id;
923 match expanded_dir_ids.binary_search(&entry_id) {
924 Ok(ix) => {
925 expanded_dir_ids.remove(ix);
926 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
927 cx.notify();
928 break;
929 }
930 Err(_) => {
931 if let Some(parent_entry) =
932 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
933 {
934 entry = parent_entry;
935 } else {
936 break;
937 }
938 }
939 }
940 }
941 }
942
943 pub fn collapse_all_entries(
944 &mut self,
945 _: &CollapseAllEntries,
946 _: &mut Window,
947 cx: &mut Context<Self>,
948 ) {
949 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
950 // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
951 self.expanded_dir_ids
952 .retain(|_, expanded_entries| expanded_entries.is_empty());
953 self.update_visible_entries(None, cx);
954 cx.notify();
955 }
956
957 fn toggle_expanded(
958 &mut self,
959 entry_id: ProjectEntryId,
960 window: &mut Window,
961 cx: &mut Context<Self>,
962 ) {
963 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
964 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
965 self.project.update(cx, |project, cx| {
966 match expanded_dir_ids.binary_search(&entry_id) {
967 Ok(ix) => {
968 expanded_dir_ids.remove(ix);
969 }
970 Err(ix) => {
971 project.expand_entry(worktree_id, entry_id, cx);
972 expanded_dir_ids.insert(ix, entry_id);
973 }
974 }
975 });
976 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
977 window.focus(&self.focus_handle);
978 cx.notify();
979 }
980 }
981 }
982
983 fn toggle_expand_all(
984 &mut self,
985 entry_id: ProjectEntryId,
986 window: &mut Window,
987 cx: &mut Context<Self>,
988 ) {
989 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
990 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
991 match expanded_dir_ids.binary_search(&entry_id) {
992 Ok(_ix) => {
993 self.collapse_all_for_entry(worktree_id, entry_id, cx);
994 }
995 Err(_ix) => {
996 self.expand_all_for_entry(worktree_id, entry_id, cx);
997 }
998 }
999 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1000 window.focus(&self.focus_handle);
1001 cx.notify();
1002 }
1003 }
1004 }
1005
1006 fn expand_all_for_entry(
1007 &mut self,
1008 worktree_id: WorktreeId,
1009 entry_id: ProjectEntryId,
1010 cx: &mut Context<Self>,
1011 ) {
1012 self.project.update(cx, |project, cx| {
1013 if let Some((worktree, expanded_dir_ids)) = project
1014 .worktree_for_id(worktree_id, cx)
1015 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1016 {
1017 if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
1018 task.detach();
1019 }
1020
1021 let worktree = worktree.read(cx);
1022
1023 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1024 loop {
1025 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1026 expanded_dir_ids.insert(ix, entry.id);
1027 }
1028
1029 if let Some(parent_entry) =
1030 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1031 {
1032 entry = parent_entry;
1033 } else {
1034 break;
1035 }
1036 }
1037 }
1038 }
1039 });
1040 }
1041
1042 fn collapse_all_for_entry(
1043 &mut self,
1044 worktree_id: WorktreeId,
1045 entry_id: ProjectEntryId,
1046 cx: &mut Context<Self>,
1047 ) {
1048 self.project.update(cx, |project, cx| {
1049 if let Some((worktree, expanded_dir_ids)) = project
1050 .worktree_for_id(worktree_id, cx)
1051 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1052 {
1053 let worktree = worktree.read(cx);
1054 let mut dirs_to_collapse = vec![entry_id];
1055 let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1056 while let Some(current_id) = dirs_to_collapse.pop() {
1057 let Some(current_entry) = worktree.entry_for_id(current_id) else {
1058 continue;
1059 };
1060 if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
1061 expanded_dir_ids.remove(ix);
1062 }
1063 if auto_fold_enabled {
1064 self.unfolded_dir_ids.remove(¤t_id);
1065 }
1066 for child in worktree.child_entries(¤t_entry.path) {
1067 if child.is_dir() {
1068 dirs_to_collapse.push(child.id);
1069 }
1070 }
1071 }
1072 }
1073 });
1074 }
1075
1076 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1077 if let Some(edit_state) = &self.edit_state {
1078 if edit_state.processing_filename.is_none() {
1079 self.filename_editor.update(cx, |editor, cx| {
1080 editor.move_to_beginning_of_line(
1081 &editor::actions::MoveToBeginningOfLine {
1082 stop_at_soft_wraps: false,
1083 stop_at_indent: false,
1084 },
1085 window,
1086 cx,
1087 );
1088 });
1089 return;
1090 }
1091 }
1092 if let Some(selection) = self.selection {
1093 let (mut worktree_ix, mut entry_ix, _) =
1094 self.index_for_selection(selection).unwrap_or_default();
1095 if entry_ix > 0 {
1096 entry_ix -= 1;
1097 } else if worktree_ix > 0 {
1098 worktree_ix -= 1;
1099 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
1100 } else {
1101 return;
1102 }
1103
1104 let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
1105 let selection = SelectedEntry {
1106 worktree_id: *worktree_id,
1107 entry_id: worktree_entries[entry_ix].id,
1108 };
1109 self.selection = Some(selection);
1110 if window.modifiers().shift {
1111 self.marked_entries.insert(selection);
1112 }
1113 self.autoscroll(cx);
1114 cx.notify();
1115 } else {
1116 self.select_first(&SelectFirst {}, window, cx);
1117 }
1118 }
1119
1120 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1121 if let Some(task) = self.confirm_edit(window, cx) {
1122 task.detach_and_notify_err(window, cx);
1123 }
1124 }
1125
1126 fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
1127 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
1128 self.open_internal(true, !preview_tabs_enabled, window, cx);
1129 }
1130
1131 fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context<Self>) {
1132 self.open_internal(false, true, window, cx);
1133 }
1134
1135 fn open_internal(
1136 &mut self,
1137 allow_preview: bool,
1138 focus_opened_item: bool,
1139 window: &mut Window,
1140 cx: &mut Context<Self>,
1141 ) {
1142 if let Some((_, entry)) = self.selected_entry(cx) {
1143 if entry.is_file() {
1144 self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
1145 cx.notify();
1146 } else {
1147 self.toggle_expanded(entry.id, window, cx);
1148 }
1149 }
1150 }
1151
1152 fn populate_validation_error(&mut self, cx: &mut Context<Self>) {
1153 let edit_state = match self.edit_state.as_mut() {
1154 Some(state) => state,
1155 None => return,
1156 };
1157 let filename = self.filename_editor.read(cx).text(cx);
1158 if !filename.is_empty() {
1159 if let Some(worktree) = self
1160 .project
1161 .read(cx)
1162 .worktree_for_id(edit_state.worktree_id, cx)
1163 {
1164 if let Some(entry) = worktree.read(cx).entry_for_id(edit_state.entry_id) {
1165 let mut already_exists = false;
1166 if edit_state.is_new_entry() {
1167 let new_path = entry.path.join(filename.trim_start_matches('/'));
1168 if worktree
1169 .read(cx)
1170 .entry_for_path(new_path.as_path())
1171 .is_some()
1172 {
1173 already_exists = true;
1174 }
1175 } else {
1176 let new_path = if let Some(parent) = entry.path.clone().parent() {
1177 parent.join(&filename)
1178 } else {
1179 filename.clone().into()
1180 };
1181 if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path())
1182 {
1183 if existing.id != entry.id {
1184 already_exists = true;
1185 }
1186 }
1187 };
1188 if already_exists {
1189 edit_state.validation_state = ValidationState::Error(format!(
1190 "File or directory '{}' already exists at location. Please choose a different name.",
1191 filename
1192 ));
1193 cx.notify();
1194 return;
1195 }
1196 }
1197 }
1198 let trimmed_filename = filename.trim();
1199 if trimmed_filename.is_empty() {
1200 edit_state.validation_state =
1201 ValidationState::Error("File or directory name cannot be empty.".to_string());
1202 cx.notify();
1203 return;
1204 }
1205 if trimmed_filename != filename {
1206 edit_state.validation_state = ValidationState::Warning(
1207 "File or directory name contains leading or trailing whitespace.".to_string(),
1208 );
1209 cx.notify();
1210 return;
1211 }
1212 }
1213 edit_state.validation_state = ValidationState::None;
1214 cx.notify();
1215 }
1216
1217 fn confirm_edit(
1218 &mut self,
1219 window: &mut Window,
1220 cx: &mut Context<Self>,
1221 ) -> Option<Task<Result<()>>> {
1222 let edit_state = self.edit_state.as_mut()?;
1223 let worktree_id = edit_state.worktree_id;
1224 let is_new_entry = edit_state.is_new_entry();
1225 let filename = self.filename_editor.read(cx).text(cx);
1226 if filename.trim().is_empty() {
1227 return None;
1228 }
1229 #[cfg(not(target_os = "windows"))]
1230 let filename_indicates_dir = filename.ends_with("/");
1231 // On Windows, path separator could be either `/` or `\`.
1232 #[cfg(target_os = "windows")]
1233 let filename_indicates_dir = filename.ends_with("/") || filename.ends_with("\\");
1234 edit_state.is_dir =
1235 edit_state.is_dir || (edit_state.is_new_entry() && filename_indicates_dir);
1236 let is_dir = edit_state.is_dir;
1237 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
1238 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
1239
1240 let edit_task;
1241 let edited_entry_id;
1242 if is_new_entry {
1243 self.selection = Some(SelectedEntry {
1244 worktree_id,
1245 entry_id: NEW_ENTRY_ID,
1246 });
1247 let new_path = entry.path.join(filename.trim_start_matches('/'));
1248 if worktree
1249 .read(cx)
1250 .entry_for_path(new_path.as_path())
1251 .is_some()
1252 {
1253 return None;
1254 }
1255
1256 edited_entry_id = NEW_ENTRY_ID;
1257 edit_task = self.project.update(cx, |project, cx| {
1258 project.create_entry((worktree_id, &new_path), is_dir, cx)
1259 });
1260 } else {
1261 let new_path = if let Some(parent) = entry.path.clone().parent() {
1262 parent.join(&filename)
1263 } else {
1264 filename.clone().into()
1265 };
1266 if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path()) {
1267 if existing.id == entry.id {
1268 window.focus(&self.focus_handle);
1269 }
1270 return None;
1271 }
1272 edited_entry_id = entry.id;
1273 edit_task = self.project.update(cx, |project, cx| {
1274 project.rename_entry(entry.id, new_path.as_path(), cx)
1275 });
1276 };
1277
1278 window.focus(&self.focus_handle);
1279 edit_state.processing_filename = Some(filename);
1280 cx.notify();
1281
1282 Some(cx.spawn_in(window, async move |project_panel, cx| {
1283 let new_entry = edit_task.await;
1284 project_panel.update(cx, |project_panel, cx| {
1285 project_panel.edit_state = None;
1286 cx.notify();
1287 })?;
1288
1289 match new_entry {
1290 Err(e) => {
1291 project_panel.update( cx, |project_panel, cx| {
1292 project_panel.marked_entries.clear();
1293 project_panel.update_visible_entries(None, cx);
1294 }).ok();
1295 Err(e)?;
1296 }
1297 Ok(CreatedEntry::Included(new_entry)) => {
1298 project_panel.update( cx, |project_panel, cx| {
1299 if let Some(selection) = &mut project_panel.selection {
1300 if selection.entry_id == edited_entry_id {
1301 selection.worktree_id = worktree_id;
1302 selection.entry_id = new_entry.id;
1303 project_panel.marked_entries.clear();
1304 project_panel.expand_to_selection(cx);
1305 }
1306 }
1307 project_panel.update_visible_entries(None, cx);
1308 if is_new_entry && !is_dir {
1309 project_panel.open_entry(new_entry.id, true, false, cx);
1310 }
1311 cx.notify();
1312 })?;
1313 }
1314 Ok(CreatedEntry::Excluded { abs_path }) => {
1315 if let Some(open_task) = project_panel
1316 .update_in( cx, |project_panel, window, cx| {
1317 project_panel.marked_entries.clear();
1318 project_panel.update_visible_entries(None, cx);
1319
1320 if is_dir {
1321 project_panel.project.update(cx, |_, cx| {
1322 cx.emit(project::Event::Toast {
1323 notification_id: "excluded-directory".into(),
1324 message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
1325 })
1326 });
1327 None
1328 } else {
1329 project_panel
1330 .workspace
1331 .update(cx, |workspace, cx| {
1332 workspace.open_abs_path(abs_path, OpenOptions { visible: Some(OpenVisible::All), ..Default::default() }, window, cx)
1333 })
1334 .ok()
1335 }
1336 })
1337 .ok()
1338 .flatten()
1339 {
1340 let _ = open_task.await?;
1341 }
1342 }
1343 }
1344 Ok(())
1345 }))
1346 }
1347
1348 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1349 let previous_edit_state = self.edit_state.take();
1350 self.update_visible_entries(None, cx);
1351 self.marked_entries.clear();
1352
1353 if let Some(previously_focused) =
1354 previous_edit_state.and_then(|edit_state| edit_state.previously_focused)
1355 {
1356 self.selection = Some(previously_focused);
1357 self.autoscroll(cx);
1358 }
1359
1360 window.focus(&self.focus_handle);
1361 cx.notify();
1362 }
1363
1364 fn open_entry(
1365 &mut self,
1366 entry_id: ProjectEntryId,
1367 focus_opened_item: bool,
1368 allow_preview: bool,
1369
1370 cx: &mut Context<Self>,
1371 ) {
1372 cx.emit(Event::OpenedEntry {
1373 entry_id,
1374 focus_opened_item,
1375 allow_preview,
1376 });
1377 }
1378
1379 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut Context<Self>) {
1380 cx.emit(Event::SplitEntry { entry_id });
1381 }
1382
1383 fn new_file(&mut self, _: &NewFile, window: &mut Window, cx: &mut Context<Self>) {
1384 self.add_entry(false, window, cx)
1385 }
1386
1387 fn new_directory(&mut self, _: &NewDirectory, window: &mut Window, cx: &mut Context<Self>) {
1388 self.add_entry(true, window, cx)
1389 }
1390
1391 fn add_entry(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context<Self>) {
1392 let Some((worktree_id, entry_id)) = self
1393 .selection
1394 .map(|entry| (entry.worktree_id, entry.entry_id))
1395 .or_else(|| {
1396 let entry_id = self.last_worktree_root_id?;
1397 let worktree_id = self
1398 .project
1399 .read(cx)
1400 .worktree_for_entry(entry_id, cx)?
1401 .read(cx)
1402 .id();
1403
1404 self.selection = Some(SelectedEntry {
1405 worktree_id,
1406 entry_id,
1407 });
1408
1409 Some((worktree_id, entry_id))
1410 })
1411 else {
1412 return;
1413 };
1414
1415 let directory_id;
1416 let new_entry_id = self.resolve_entry(entry_id);
1417 if let Some((worktree, expanded_dir_ids)) = self
1418 .project
1419 .read(cx)
1420 .worktree_for_id(worktree_id, cx)
1421 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1422 {
1423 let worktree = worktree.read(cx);
1424 if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
1425 loop {
1426 if entry.is_dir() {
1427 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1428 expanded_dir_ids.insert(ix, entry.id);
1429 }
1430 directory_id = entry.id;
1431 break;
1432 } else {
1433 if let Some(parent_path) = entry.path.parent() {
1434 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1435 entry = parent_entry;
1436 continue;
1437 }
1438 }
1439 return;
1440 }
1441 }
1442 } else {
1443 return;
1444 };
1445 } else {
1446 return;
1447 };
1448
1449 self.marked_entries.clear();
1450 self.edit_state = Some(EditState {
1451 worktree_id,
1452 entry_id: directory_id,
1453 leaf_entry_id: None,
1454 is_dir,
1455 processing_filename: None,
1456 previously_focused: self.selection,
1457 depth: 0,
1458 validation_state: ValidationState::None,
1459 });
1460 self.filename_editor.update(cx, |editor, cx| {
1461 editor.clear(window, cx);
1462 window.focus(&editor.focus_handle(cx));
1463 });
1464 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1465 self.autoscroll(cx);
1466 cx.notify();
1467 }
1468
1469 fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1470 if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1471 ancestors
1472 .ancestors
1473 .get(ancestors.current_ancestor_depth)
1474 .copied()
1475 .unwrap_or(leaf_entry_id)
1476 } else {
1477 leaf_entry_id
1478 }
1479 }
1480
1481 fn rename_impl(
1482 &mut self,
1483 selection: Option<Range<usize>>,
1484 window: &mut Window,
1485 cx: &mut Context<Self>,
1486 ) {
1487 if let Some(SelectedEntry {
1488 worktree_id,
1489 entry_id,
1490 }) = self.selection
1491 {
1492 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1493 let sub_entry_id = self.unflatten_entry_id(entry_id);
1494 if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1495 #[cfg(target_os = "windows")]
1496 if Some(entry) == worktree.read(cx).root_entry() {
1497 return;
1498 }
1499 self.edit_state = Some(EditState {
1500 worktree_id,
1501 entry_id: sub_entry_id,
1502 leaf_entry_id: Some(entry_id),
1503 is_dir: entry.is_dir(),
1504 processing_filename: None,
1505 previously_focused: None,
1506 depth: 0,
1507 validation_state: ValidationState::None,
1508 });
1509 let file_name = entry
1510 .path
1511 .file_name()
1512 .map(|s| s.to_string_lossy())
1513 .unwrap_or_default()
1514 .to_string();
1515 let selection = selection.unwrap_or_else(|| {
1516 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1517 let selection_end =
1518 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1519 0..selection_end
1520 });
1521 self.filename_editor.update(cx, |editor, cx| {
1522 editor.set_text(file_name, window, cx);
1523 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1524 s.select_ranges([selection])
1525 });
1526 window.focus(&editor.focus_handle(cx));
1527 });
1528 self.update_visible_entries(None, cx);
1529 self.autoscroll(cx);
1530 cx.notify();
1531 }
1532 }
1533 }
1534 }
1535
1536 fn rename(&mut self, _: &Rename, window: &mut Window, cx: &mut Context<Self>) {
1537 self.rename_impl(None, window, cx);
1538 }
1539
1540 fn trash(&mut self, action: &Trash, window: &mut Window, cx: &mut Context<Self>) {
1541 self.remove(true, action.skip_prompt, window, cx);
1542 }
1543
1544 fn delete(&mut self, action: &Delete, window: &mut Window, cx: &mut Context<Self>) {
1545 self.remove(false, action.skip_prompt, window, cx);
1546 }
1547
1548 fn remove(
1549 &mut self,
1550 trash: bool,
1551 skip_prompt: bool,
1552 window: &mut Window,
1553 cx: &mut Context<ProjectPanel>,
1554 ) {
1555 maybe!({
1556 let items_to_delete = self.disjoint_entries(cx);
1557 if items_to_delete.is_empty() {
1558 return None;
1559 }
1560 let project = self.project.read(cx);
1561
1562 let mut dirty_buffers = 0;
1563 let file_paths = items_to_delete
1564 .iter()
1565 .filter_map(|selection| {
1566 let project_path = project.path_for_entry(selection.entry_id, cx)?;
1567 dirty_buffers +=
1568 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
1569 Some((
1570 selection.entry_id,
1571 project_path
1572 .path
1573 .file_name()?
1574 .to_string_lossy()
1575 .into_owned(),
1576 ))
1577 })
1578 .collect::<Vec<_>>();
1579 if file_paths.is_empty() {
1580 return None;
1581 }
1582 let answer = if !skip_prompt {
1583 let operation = if trash { "Trash" } else { "Delete" };
1584 let prompt = match file_paths.first() {
1585 Some((_, path)) if file_paths.len() == 1 => {
1586 let unsaved_warning = if dirty_buffers > 0 {
1587 "\n\nIt has unsaved changes, which will be lost."
1588 } else {
1589 ""
1590 };
1591
1592 format!("{operation} {path}?{unsaved_warning}")
1593 }
1594 _ => {
1595 const CUTOFF_POINT: usize = 10;
1596 let names = if file_paths.len() > CUTOFF_POINT {
1597 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1598 let mut paths = file_paths
1599 .iter()
1600 .map(|(_, path)| path.clone())
1601 .take(CUTOFF_POINT)
1602 .collect::<Vec<_>>();
1603 paths.truncate(CUTOFF_POINT);
1604 if truncated_path_counts == 1 {
1605 paths.push(".. 1 file not shown".into());
1606 } else {
1607 paths.push(format!(".. {} files not shown", truncated_path_counts));
1608 }
1609 paths
1610 } else {
1611 file_paths.iter().map(|(_, path)| path.clone()).collect()
1612 };
1613 let unsaved_warning = if dirty_buffers == 0 {
1614 String::new()
1615 } else if dirty_buffers == 1 {
1616 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
1617 } else {
1618 format!(
1619 "\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
1620 )
1621 };
1622
1623 format!(
1624 "Do you want to {} the following {} files?\n{}{unsaved_warning}",
1625 operation.to_lowercase(),
1626 file_paths.len(),
1627 names.join("\n")
1628 )
1629 }
1630 };
1631 Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx))
1632 } else {
1633 None
1634 };
1635 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
1636 cx.spawn_in(window, async move |panel, cx| {
1637 if let Some(answer) = answer {
1638 if answer.await != Ok(0) {
1639 return anyhow::Ok(());
1640 }
1641 }
1642 for (entry_id, _) in file_paths {
1643 panel
1644 .update(cx, |panel, cx| {
1645 panel
1646 .project
1647 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1648 .context("no such entry")
1649 })??
1650 .await?;
1651 }
1652 panel.update_in(cx, |panel, window, cx| {
1653 if let Some(next_selection) = next_selection {
1654 panel.selection = Some(next_selection);
1655 panel.autoscroll(cx);
1656 } else {
1657 panel.select_last(&SelectLast {}, window, cx);
1658 }
1659 })?;
1660 Ok(())
1661 })
1662 .detach_and_log_err(cx);
1663 Some(())
1664 });
1665 }
1666
1667 fn find_next_selection_after_deletion(
1668 &self,
1669 sanitized_entries: BTreeSet<SelectedEntry>,
1670 cx: &mut Context<Self>,
1671 ) -> Option<SelectedEntry> {
1672 if sanitized_entries.is_empty() {
1673 return None;
1674 }
1675 let project = self.project.read(cx);
1676 let (worktree_id, worktree) = sanitized_entries
1677 .iter()
1678 .map(|entry| entry.worktree_id)
1679 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
1680 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
1681 let git_store = project.git_store().read(cx);
1682
1683 let marked_entries_in_worktree = sanitized_entries
1684 .iter()
1685 .filter(|e| e.worktree_id == worktree_id)
1686 .collect::<HashSet<_>>();
1687 let latest_entry = marked_entries_in_worktree
1688 .iter()
1689 .max_by(|a, b| {
1690 match (
1691 worktree.entry_for_id(a.entry_id),
1692 worktree.entry_for_id(b.entry_id),
1693 ) {
1694 (Some(a), Some(b)) => {
1695 compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
1696 }
1697 _ => cmp::Ordering::Equal,
1698 }
1699 })
1700 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
1701
1702 let parent_path = latest_entry.path.parent()?;
1703 let parent_entry = worktree.entry_for_path(parent_path)?;
1704
1705 // Remove all siblings that are being deleted except the last marked entry
1706 let repo_snapshots = git_store.repo_snapshots(cx);
1707 let worktree_snapshot = worktree.snapshot();
1708 let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore;
1709 let mut siblings: Vec<_> =
1710 ChildEntriesGitIter::new(&repo_snapshots, &worktree_snapshot, parent_path)
1711 .filter(|sibling| {
1712 (sibling.id == latest_entry.id)
1713 || (!marked_entries_in_worktree.contains(&&SelectedEntry {
1714 worktree_id,
1715 entry_id: sibling.id,
1716 }) && (!hide_gitignore || !sibling.is_ignored))
1717 })
1718 .map(|entry| entry.to_owned())
1719 .collect();
1720
1721 project::sort_worktree_entries(&mut siblings);
1722 let sibling_entry_index = siblings
1723 .iter()
1724 .position(|sibling| sibling.id == latest_entry.id)?;
1725
1726 if let Some(next_sibling) = sibling_entry_index
1727 .checked_add(1)
1728 .and_then(|i| siblings.get(i))
1729 {
1730 return Some(SelectedEntry {
1731 worktree_id,
1732 entry_id: next_sibling.id,
1733 });
1734 }
1735 if let Some(prev_sibling) = sibling_entry_index
1736 .checked_sub(1)
1737 .and_then(|i| siblings.get(i))
1738 {
1739 return Some(SelectedEntry {
1740 worktree_id,
1741 entry_id: prev_sibling.id,
1742 });
1743 }
1744 // No neighbour sibling found, fall back to parent
1745 Some(SelectedEntry {
1746 worktree_id,
1747 entry_id: parent_entry.id,
1748 })
1749 }
1750
1751 fn unfold_directory(&mut self, _: &UnfoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1752 if let Some((worktree, entry)) = self.selected_entry(cx) {
1753 self.unfolded_dir_ids.insert(entry.id);
1754
1755 let snapshot = worktree.snapshot();
1756 let mut parent_path = entry.path.parent();
1757 while let Some(path) = parent_path {
1758 if let Some(parent_entry) = worktree.entry_for_path(path) {
1759 let mut children_iter = snapshot.child_entries(path);
1760
1761 if children_iter.by_ref().take(2).count() > 1 {
1762 break;
1763 }
1764
1765 self.unfolded_dir_ids.insert(parent_entry.id);
1766 parent_path = path.parent();
1767 } else {
1768 break;
1769 }
1770 }
1771
1772 self.update_visible_entries(None, cx);
1773 self.autoscroll(cx);
1774 cx.notify();
1775 }
1776 }
1777
1778 fn fold_directory(&mut self, _: &FoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1779 if let Some((worktree, entry)) = self.selected_entry(cx) {
1780 self.unfolded_dir_ids.remove(&entry.id);
1781
1782 let snapshot = worktree.snapshot();
1783 let mut path = &*entry.path;
1784 loop {
1785 let mut child_entries_iter = snapshot.child_entries(path);
1786 if let Some(child) = child_entries_iter.next() {
1787 if child_entries_iter.next().is_none() && child.is_dir() {
1788 self.unfolded_dir_ids.remove(&child.id);
1789 path = &*child.path;
1790 } else {
1791 break;
1792 }
1793 } else {
1794 break;
1795 }
1796 }
1797
1798 self.update_visible_entries(None, cx);
1799 self.autoscroll(cx);
1800 cx.notify();
1801 }
1802 }
1803
1804 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1805 if let Some(edit_state) = &self.edit_state {
1806 if edit_state.processing_filename.is_none() {
1807 self.filename_editor.update(cx, |editor, cx| {
1808 editor.move_to_end_of_line(
1809 &editor::actions::MoveToEndOfLine {
1810 stop_at_soft_wraps: false,
1811 },
1812 window,
1813 cx,
1814 );
1815 });
1816 return;
1817 }
1818 }
1819 if let Some(selection) = self.selection {
1820 let (mut worktree_ix, mut entry_ix, _) =
1821 self.index_for_selection(selection).unwrap_or_default();
1822 if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1823 if entry_ix + 1 < worktree_entries.len() {
1824 entry_ix += 1;
1825 } else {
1826 worktree_ix += 1;
1827 entry_ix = 0;
1828 }
1829 }
1830
1831 if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1832 {
1833 if let Some(entry) = worktree_entries.get(entry_ix) {
1834 let selection = SelectedEntry {
1835 worktree_id: *worktree_id,
1836 entry_id: entry.id,
1837 };
1838 self.selection = Some(selection);
1839 if window.modifiers().shift {
1840 self.marked_entries.insert(selection);
1841 }
1842
1843 self.autoscroll(cx);
1844 cx.notify();
1845 }
1846 }
1847 } else {
1848 self.select_first(&SelectFirst {}, window, cx);
1849 }
1850 }
1851
1852 fn select_prev_diagnostic(
1853 &mut self,
1854 _: &SelectPrevDiagnostic,
1855 _: &mut Window,
1856 cx: &mut Context<Self>,
1857 ) {
1858 let selection = self.find_entry(
1859 self.selection.as_ref(),
1860 true,
1861 |entry, worktree_id| {
1862 (self.selection.is_none()
1863 || self.selection.is_some_and(|selection| {
1864 if selection.worktree_id == worktree_id {
1865 selection.entry_id != entry.id
1866 } else {
1867 true
1868 }
1869 }))
1870 && entry.is_file()
1871 && self
1872 .diagnostics
1873 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1874 },
1875 cx,
1876 );
1877
1878 if let Some(selection) = selection {
1879 self.selection = Some(selection);
1880 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1881 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1882 self.autoscroll(cx);
1883 cx.notify();
1884 }
1885 }
1886
1887 fn select_next_diagnostic(
1888 &mut self,
1889 _: &SelectNextDiagnostic,
1890 _: &mut Window,
1891 cx: &mut Context<Self>,
1892 ) {
1893 let selection = self.find_entry(
1894 self.selection.as_ref(),
1895 false,
1896 |entry, worktree_id| {
1897 (self.selection.is_none()
1898 || self.selection.is_some_and(|selection| {
1899 if selection.worktree_id == worktree_id {
1900 selection.entry_id != entry.id
1901 } else {
1902 true
1903 }
1904 }))
1905 && entry.is_file()
1906 && self
1907 .diagnostics
1908 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1909 },
1910 cx,
1911 );
1912
1913 if let Some(selection) = selection {
1914 self.selection = Some(selection);
1915 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1916 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1917 self.autoscroll(cx);
1918 cx.notify();
1919 }
1920 }
1921
1922 fn select_prev_git_entry(
1923 &mut self,
1924 _: &SelectPrevGitEntry,
1925 _: &mut Window,
1926 cx: &mut Context<Self>,
1927 ) {
1928 let selection = self.find_entry(
1929 self.selection.as_ref(),
1930 true,
1931 |entry, worktree_id| {
1932 (self.selection.is_none()
1933 || self.selection.is_some_and(|selection| {
1934 if selection.worktree_id == worktree_id {
1935 selection.entry_id != entry.id
1936 } else {
1937 true
1938 }
1939 }))
1940 && entry.is_file()
1941 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1942 },
1943 cx,
1944 );
1945
1946 if let Some(selection) = selection {
1947 self.selection = Some(selection);
1948 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1949 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1950 self.autoscroll(cx);
1951 cx.notify();
1952 }
1953 }
1954
1955 fn select_prev_directory(
1956 &mut self,
1957 _: &SelectPrevDirectory,
1958 _: &mut Window,
1959 cx: &mut Context<Self>,
1960 ) {
1961 let selection = self.find_visible_entry(
1962 self.selection.as_ref(),
1963 true,
1964 |entry, worktree_id| {
1965 (self.selection.is_none()
1966 || self.selection.is_some_and(|selection| {
1967 if selection.worktree_id == worktree_id {
1968 selection.entry_id != entry.id
1969 } else {
1970 true
1971 }
1972 }))
1973 && entry.is_dir()
1974 },
1975 cx,
1976 );
1977
1978 if let Some(selection) = selection {
1979 self.selection = Some(selection);
1980 self.autoscroll(cx);
1981 cx.notify();
1982 }
1983 }
1984
1985 fn select_next_directory(
1986 &mut self,
1987 _: &SelectNextDirectory,
1988 _: &mut Window,
1989 cx: &mut Context<Self>,
1990 ) {
1991 let selection = self.find_visible_entry(
1992 self.selection.as_ref(),
1993 false,
1994 |entry, worktree_id| {
1995 (self.selection.is_none()
1996 || self.selection.is_some_and(|selection| {
1997 if selection.worktree_id == worktree_id {
1998 selection.entry_id != entry.id
1999 } else {
2000 true
2001 }
2002 }))
2003 && entry.is_dir()
2004 },
2005 cx,
2006 );
2007
2008 if let Some(selection) = selection {
2009 self.selection = Some(selection);
2010 self.autoscroll(cx);
2011 cx.notify();
2012 }
2013 }
2014
2015 fn select_next_git_entry(
2016 &mut self,
2017 _: &SelectNextGitEntry,
2018 _: &mut Window,
2019 cx: &mut Context<Self>,
2020 ) {
2021 let selection = self.find_entry(
2022 self.selection.as_ref(),
2023 false,
2024 |entry, worktree_id| {
2025 (self.selection.is_none()
2026 || self.selection.is_some_and(|selection| {
2027 if selection.worktree_id == worktree_id {
2028 selection.entry_id != entry.id
2029 } else {
2030 true
2031 }
2032 }))
2033 && entry.is_file()
2034 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2035 },
2036 cx,
2037 );
2038
2039 if let Some(selection) = selection {
2040 self.selection = Some(selection);
2041 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2042 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
2043 self.autoscroll(cx);
2044 cx.notify();
2045 }
2046 }
2047
2048 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
2049 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2050 if let Some(parent) = entry.path.parent() {
2051 let worktree = worktree.read(cx);
2052 if let Some(parent_entry) = worktree.entry_for_path(parent) {
2053 self.selection = Some(SelectedEntry {
2054 worktree_id: worktree.id(),
2055 entry_id: parent_entry.id,
2056 });
2057 self.autoscroll(cx);
2058 cx.notify();
2059 }
2060 }
2061 } else {
2062 self.select_first(&SelectFirst {}, window, cx);
2063 }
2064 }
2065
2066 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
2067 let worktree = self
2068 .visible_entries
2069 .first()
2070 .and_then(|(worktree_id, _, _)| {
2071 self.project.read(cx).worktree_for_id(*worktree_id, cx)
2072 });
2073 if let Some(worktree) = worktree {
2074 let worktree = worktree.read(cx);
2075 let worktree_id = worktree.id();
2076 if let Some(root_entry) = worktree.root_entry() {
2077 let selection = SelectedEntry {
2078 worktree_id,
2079 entry_id: root_entry.id,
2080 };
2081 self.selection = Some(selection);
2082 if window.modifiers().shift {
2083 self.marked_entries.insert(selection);
2084 }
2085 self.autoscroll(cx);
2086 cx.notify();
2087 }
2088 }
2089 }
2090
2091 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
2092 if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.last() {
2093 let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
2094 if let (Some(worktree), Some(entry)) = (worktree, visible_worktree_entries.last()) {
2095 let worktree = worktree.read(cx);
2096 if let Some(entry) = worktree.entry_for_id(entry.id) {
2097 let selection = SelectedEntry {
2098 worktree_id: *worktree_id,
2099 entry_id: entry.id,
2100 };
2101 self.selection = Some(selection);
2102 self.autoscroll(cx);
2103 cx.notify();
2104 }
2105 }
2106 }
2107 }
2108
2109 fn autoscroll(&mut self, cx: &mut Context<Self>) {
2110 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2111 self.scroll_handle
2112 .scroll_to_item(index, ScrollStrategy::Center);
2113 cx.notify();
2114 }
2115 }
2116
2117 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
2118 let entries = self.disjoint_entries(cx);
2119 if !entries.is_empty() {
2120 self.clipboard = Some(ClipboardEntry::Cut(entries));
2121 cx.notify();
2122 }
2123 }
2124
2125 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
2126 let entries = self.disjoint_entries(cx);
2127 if !entries.is_empty() {
2128 self.clipboard = Some(ClipboardEntry::Copied(entries));
2129 cx.notify();
2130 }
2131 }
2132
2133 fn create_paste_path(
2134 &self,
2135 source: &SelectedEntry,
2136 (worktree, target_entry): (Entity<Worktree>, &Entry),
2137 cx: &App,
2138 ) -> Option<(PathBuf, Option<Range<usize>>)> {
2139 let mut new_path = target_entry.path.to_path_buf();
2140 // If we're pasting into a file, or a directory into itself, go up one level.
2141 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
2142 new_path.pop();
2143 }
2144 let clipboard_entry_file_name = self
2145 .project
2146 .read(cx)
2147 .path_for_entry(source.entry_id, cx)?
2148 .path
2149 .file_name()?
2150 .to_os_string();
2151 new_path.push(&clipboard_entry_file_name);
2152 let extension = new_path.extension().map(|e| e.to_os_string());
2153 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
2154 let file_name_len = file_name_without_extension.to_string_lossy().len();
2155 let mut disambiguation_range = None;
2156 let mut ix = 0;
2157 {
2158 let worktree = worktree.read(cx);
2159 while worktree.entry_for_path(&new_path).is_some() {
2160 new_path.pop();
2161
2162 let mut new_file_name = file_name_without_extension.to_os_string();
2163
2164 let disambiguation = " copy";
2165 let mut disambiguation_len = disambiguation.len();
2166
2167 new_file_name.push(disambiguation);
2168
2169 if ix > 0 {
2170 let extra_disambiguation = format!(" {}", ix);
2171 disambiguation_len += extra_disambiguation.len();
2172
2173 new_file_name.push(extra_disambiguation);
2174 }
2175 if let Some(extension) = extension.as_ref() {
2176 new_file_name.push(".");
2177 new_file_name.push(extension);
2178 }
2179
2180 new_path.push(new_file_name);
2181 disambiguation_range = Some(file_name_len..(file_name_len + disambiguation_len));
2182 ix += 1;
2183 }
2184 }
2185 Some((new_path, disambiguation_range))
2186 }
2187
2188 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2189 maybe!({
2190 let (worktree, entry) = self.selected_entry_handle(cx)?;
2191 let entry = entry.clone();
2192 let worktree_id = worktree.read(cx).id();
2193 let clipboard_entries = self
2194 .clipboard
2195 .as_ref()
2196 .filter(|clipboard| !clipboard.items().is_empty())?;
2197 enum PasteTask {
2198 Rename(Task<Result<CreatedEntry>>),
2199 Copy(Task<Result<Option<Entry>>>),
2200 }
2201 let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
2202 IndexMap::default();
2203 let mut disambiguation_range = None;
2204 let clip_is_cut = clipboard_entries.is_cut();
2205 for clipboard_entry in clipboard_entries.items() {
2206 let (new_path, new_disambiguation_range) =
2207 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
2208 let clip_entry_id = clipboard_entry.entry_id;
2209 let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
2210 let relative_worktree_source_path = if !is_same_worktree {
2211 let target_base_path = worktree.read(cx).abs_path();
2212 let clipboard_project_path =
2213 self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
2214 let clipboard_abs_path = self
2215 .project
2216 .read(cx)
2217 .absolute_path(&clipboard_project_path, cx)?;
2218 Some(relativize_path(
2219 &target_base_path,
2220 clipboard_abs_path.as_path(),
2221 ))
2222 } else {
2223 None
2224 };
2225 let task = if clip_is_cut && is_same_worktree {
2226 let task = self.project.update(cx, |project, cx| {
2227 project.rename_entry(clip_entry_id, new_path, cx)
2228 });
2229 PasteTask::Rename(task)
2230 } else {
2231 let entry_id = if is_same_worktree {
2232 clip_entry_id
2233 } else {
2234 entry.id
2235 };
2236 let task = self.project.update(cx, |project, cx| {
2237 project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
2238 });
2239 PasteTask::Copy(task)
2240 };
2241 let needs_delete = !is_same_worktree && clip_is_cut;
2242 paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
2243 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2244 }
2245
2246 let item_count = paste_entry_tasks.len();
2247
2248 cx.spawn_in(window, async move |project_panel, cx| {
2249 let mut last_succeed = None;
2250 let mut need_delete_ids = Vec::new();
2251 for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
2252 match task {
2253 PasteTask::Rename(task) => {
2254 if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
2255 last_succeed = Some(entry);
2256 }
2257 }
2258 PasteTask::Copy(task) => {
2259 if let Some(Some(entry)) = task.await.log_err() {
2260 last_succeed = Some(entry);
2261 if need_delete {
2262 need_delete_ids.push(entry_id);
2263 }
2264 }
2265 }
2266 }
2267 }
2268 // remove entry for cut in difference worktree
2269 for entry_id in need_delete_ids {
2270 project_panel
2271 .update(cx, |project_panel, cx| {
2272 project_panel
2273 .project
2274 .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
2275 .ok_or_else(|| anyhow!("no such entry"))
2276 })??
2277 .await?;
2278 }
2279 // update selection
2280 if let Some(entry) = last_succeed {
2281 project_panel
2282 .update_in(cx, |project_panel, window, cx| {
2283 project_panel.selection = Some(SelectedEntry {
2284 worktree_id,
2285 entry_id: entry.id,
2286 });
2287
2288 if item_count == 1 {
2289 // open entry if not dir, and only focus if rename is not pending
2290 if !entry.is_dir() {
2291 project_panel.open_entry(
2292 entry.id,
2293 disambiguation_range.is_none(),
2294 false,
2295 cx,
2296 );
2297 }
2298
2299 // if only one entry was pasted and it was disambiguated, open the rename editor
2300 if disambiguation_range.is_some() {
2301 cx.defer_in(window, |this, window, cx| {
2302 this.rename_impl(disambiguation_range, window, cx);
2303 });
2304 }
2305 }
2306 })
2307 .ok();
2308 }
2309
2310 anyhow::Ok(())
2311 })
2312 .detach_and_log_err(cx);
2313
2314 self.expand_entry(worktree_id, entry.id, cx);
2315 Some(())
2316 });
2317 }
2318
2319 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
2320 self.copy(&Copy {}, window, cx);
2321 self.paste(&Paste {}, window, cx);
2322 }
2323
2324 fn copy_path(
2325 &mut self,
2326 _: &zed_actions::workspace::CopyPath,
2327 _: &mut Window,
2328 cx: &mut Context<Self>,
2329 ) {
2330 let abs_file_paths = {
2331 let project = self.project.read(cx);
2332 self.effective_entries()
2333 .into_iter()
2334 .filter_map(|entry| {
2335 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
2336 Some(
2337 project
2338 .worktree_for_id(entry.worktree_id, cx)?
2339 .read(cx)
2340 .abs_path()
2341 .join(entry_path)
2342 .to_string_lossy()
2343 .to_string(),
2344 )
2345 })
2346 .collect::<Vec<_>>()
2347 };
2348 if !abs_file_paths.is_empty() {
2349 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
2350 }
2351 }
2352
2353 fn copy_relative_path(
2354 &mut self,
2355 _: &zed_actions::workspace::CopyRelativePath,
2356 _: &mut Window,
2357 cx: &mut Context<Self>,
2358 ) {
2359 let file_paths = {
2360 let project = self.project.read(cx);
2361 self.effective_entries()
2362 .into_iter()
2363 .filter_map(|entry| {
2364 Some(
2365 project
2366 .path_for_entry(entry.entry_id, cx)?
2367 .path
2368 .to_string_lossy()
2369 .to_string(),
2370 )
2371 })
2372 .collect::<Vec<_>>()
2373 };
2374 if !file_paths.is_empty() {
2375 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
2376 }
2377 }
2378
2379 fn reveal_in_finder(
2380 &mut self,
2381 _: &RevealInFileManager,
2382 _: &mut Window,
2383 cx: &mut Context<Self>,
2384 ) {
2385 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2386 cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
2387 }
2388 }
2389
2390 fn remove_from_project(
2391 &mut self,
2392 _: &RemoveFromProject,
2393 _window: &mut Window,
2394 cx: &mut Context<Self>,
2395 ) {
2396 for entry in self.effective_entries().iter() {
2397 let worktree_id = entry.worktree_id;
2398 self.project
2399 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
2400 }
2401 }
2402
2403 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
2404 if let Some((worktree, entry)) = self.selected_entry(cx) {
2405 let abs_path = worktree.abs_path().join(&entry.path);
2406 cx.open_with_system(&abs_path);
2407 }
2408 }
2409
2410 fn open_in_terminal(
2411 &mut self,
2412 _: &OpenInTerminal,
2413 window: &mut Window,
2414 cx: &mut Context<Self>,
2415 ) {
2416 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2417 let abs_path = match &entry.canonical_path {
2418 Some(canonical_path) => Some(canonical_path.to_path_buf()),
2419 None => worktree.read(cx).absolutize(&entry.path).ok(),
2420 };
2421
2422 let working_directory = if entry.is_dir() {
2423 abs_path
2424 } else {
2425 abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
2426 };
2427 if let Some(working_directory) = working_directory {
2428 window.dispatch_action(
2429 workspace::OpenTerminal { working_directory }.boxed_clone(),
2430 cx,
2431 )
2432 }
2433 }
2434 }
2435
2436 pub fn new_search_in_directory(
2437 &mut self,
2438 _: &NewSearchInDirectory,
2439 window: &mut Window,
2440 cx: &mut Context<Self>,
2441 ) {
2442 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2443 let dir_path = if entry.is_dir() {
2444 entry.path.clone()
2445 } else {
2446 // entry is a file, use its parent directory
2447 match entry.path.parent() {
2448 Some(parent) => Arc::from(parent),
2449 None => {
2450 // File at root, open search with empty filter
2451 self.workspace
2452 .update(cx, |workspace, cx| {
2453 search::ProjectSearchView::new_search_in_directory(
2454 workspace,
2455 Path::new(""),
2456 window,
2457 cx,
2458 );
2459 })
2460 .ok();
2461 return;
2462 }
2463 }
2464 };
2465
2466 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2467 let dir_path = if include_root {
2468 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2469 full_path.push(&dir_path);
2470 Arc::from(full_path)
2471 } else {
2472 dir_path
2473 };
2474
2475 self.workspace
2476 .update(cx, |workspace, cx| {
2477 search::ProjectSearchView::new_search_in_directory(
2478 workspace, &dir_path, window, cx,
2479 );
2480 })
2481 .ok();
2482 }
2483 }
2484
2485 fn move_entry(
2486 &mut self,
2487 entry_to_move: ProjectEntryId,
2488 destination: ProjectEntryId,
2489 destination_is_file: bool,
2490 cx: &mut Context<Self>,
2491 ) {
2492 if self
2493 .project
2494 .read(cx)
2495 .entry_is_worktree_root(entry_to_move, cx)
2496 {
2497 self.move_worktree_root(entry_to_move, destination, cx)
2498 } else {
2499 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2500 }
2501 }
2502
2503 fn move_worktree_root(
2504 &mut self,
2505 entry_to_move: ProjectEntryId,
2506 destination: ProjectEntryId,
2507 cx: &mut Context<Self>,
2508 ) {
2509 self.project.update(cx, |project, cx| {
2510 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2511 return;
2512 };
2513 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2514 return;
2515 };
2516
2517 let worktree_id = worktree_to_move.read(cx).id();
2518 let destination_id = destination_worktree.read(cx).id();
2519
2520 project
2521 .move_worktree(worktree_id, destination_id, cx)
2522 .log_err();
2523 });
2524 }
2525
2526 fn move_worktree_entry(
2527 &mut self,
2528 entry_to_move: ProjectEntryId,
2529 destination: ProjectEntryId,
2530 destination_is_file: bool,
2531 cx: &mut Context<Self>,
2532 ) {
2533 if entry_to_move == destination {
2534 return;
2535 }
2536
2537 let destination_worktree = self.project.update(cx, |project, cx| {
2538 let entry_path = project.path_for_entry(entry_to_move, cx)?;
2539 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2540
2541 let mut destination_path = destination_entry_path.as_ref();
2542 if destination_is_file {
2543 destination_path = destination_path.parent()?;
2544 }
2545
2546 let mut new_path = destination_path.to_path_buf();
2547 new_path.push(entry_path.path.file_name()?);
2548 if new_path != entry_path.path.as_ref() {
2549 let task = project.rename_entry(entry_to_move, new_path, cx);
2550 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2551 }
2552
2553 project.worktree_id_for_entry(destination, cx)
2554 });
2555
2556 if let Some(destination_worktree) = destination_worktree {
2557 self.expand_entry(destination_worktree, destination, cx);
2558 }
2559 }
2560
2561 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2562 let mut entry_index = 0;
2563 let mut visible_entries_index = 0;
2564 for (worktree_index, (worktree_id, worktree_entries, _)) in
2565 self.visible_entries.iter().enumerate()
2566 {
2567 if *worktree_id == selection.worktree_id {
2568 for entry in worktree_entries {
2569 if entry.id == selection.entry_id {
2570 return Some((worktree_index, entry_index, visible_entries_index));
2571 } else {
2572 visible_entries_index += 1;
2573 entry_index += 1;
2574 }
2575 }
2576 break;
2577 } else {
2578 visible_entries_index += worktree_entries.len();
2579 }
2580 }
2581 None
2582 }
2583
2584 fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
2585 let marked_entries = self.effective_entries();
2586 let mut sanitized_entries = BTreeSet::new();
2587 if marked_entries.is_empty() {
2588 return sanitized_entries;
2589 }
2590
2591 let project = self.project.read(cx);
2592 let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2593 .into_iter()
2594 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2595 .fold(HashMap::default(), |mut map, entry| {
2596 map.entry(entry.worktree_id).or_default().push(entry);
2597 map
2598 });
2599
2600 for (worktree_id, marked_entries) in marked_entries_by_worktree {
2601 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2602 let worktree = worktree.read(cx);
2603 let marked_dir_paths = marked_entries
2604 .iter()
2605 .filter_map(|entry| {
2606 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2607 if entry.is_dir() {
2608 Some(entry.path.as_ref())
2609 } else {
2610 None
2611 }
2612 })
2613 })
2614 .collect::<BTreeSet<_>>();
2615
2616 sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2617 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2618 return false;
2619 };
2620 let entry_path = entry_info.path.as_ref();
2621 let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2622 entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2623 });
2624 !inside_marked_dir
2625 }));
2626 }
2627 }
2628
2629 sanitized_entries
2630 }
2631
2632 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2633 if let Some(selection) = self.selection {
2634 let selection = SelectedEntry {
2635 entry_id: self.resolve_entry(selection.entry_id),
2636 worktree_id: selection.worktree_id,
2637 };
2638
2639 // Default to using just the selected item when nothing is marked.
2640 if self.marked_entries.is_empty() {
2641 return BTreeSet::from([selection]);
2642 }
2643
2644 // Allow operating on the selected item even when something else is marked,
2645 // making it easier to perform one-off actions without clearing a mark.
2646 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2647 return BTreeSet::from([selection]);
2648 }
2649 }
2650
2651 // Return only marked entries since we've already handled special cases where
2652 // only selection should take precedence. At this point, marked entries may or
2653 // may not include the current selection, which is intentional.
2654 self.marked_entries
2655 .iter()
2656 .map(|entry| SelectedEntry {
2657 entry_id: self.resolve_entry(entry.entry_id),
2658 worktree_id: entry.worktree_id,
2659 })
2660 .collect::<BTreeSet<_>>()
2661 }
2662
2663 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2664 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2665 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2666 self.ancestors
2667 .get(&id)
2668 .and_then(|ancestors| {
2669 if ancestors.current_ancestor_depth == 0 {
2670 return None;
2671 }
2672 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2673 })
2674 .copied()
2675 .unwrap_or(id)
2676 }
2677
2678 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
2679 let (worktree, entry) = self.selected_entry_handle(cx)?;
2680 Some((worktree.read(cx), entry))
2681 }
2682
2683 /// Compared to selected_entry, this function resolves to the currently
2684 /// selected subentry if dir auto-folding is enabled.
2685 fn selected_sub_entry<'a>(
2686 &self,
2687 cx: &'a App,
2688 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2689 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2690
2691 let resolved_id = self.resolve_entry(entry.id);
2692 if resolved_id != entry.id {
2693 let worktree = worktree.read(cx);
2694 entry = worktree.entry_for_id(resolved_id)?;
2695 }
2696 Some((worktree, entry))
2697 }
2698 fn selected_entry_handle<'a>(
2699 &self,
2700 cx: &'a App,
2701 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2702 let selection = self.selection?;
2703 let project = self.project.read(cx);
2704 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2705 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2706 Some((worktree, entry))
2707 }
2708
2709 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
2710 let (worktree, entry) = self.selected_entry(cx)?;
2711 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2712
2713 for path in entry.path.ancestors() {
2714 let Some(entry) = worktree.entry_for_path(path) else {
2715 continue;
2716 };
2717 if entry.is_dir() {
2718 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2719 expanded_dir_ids.insert(idx, entry.id);
2720 }
2721 }
2722 }
2723
2724 Some(())
2725 }
2726
2727 fn update_visible_entries(
2728 &mut self,
2729 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2730 cx: &mut Context<Self>,
2731 ) {
2732 let settings = ProjectPanelSettings::get_global(cx);
2733 let auto_collapse_dirs = settings.auto_fold_dirs;
2734 let hide_gitignore = settings.hide_gitignore;
2735 let project = self.project.read(cx);
2736 let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
2737 self.last_worktree_root_id = project
2738 .visible_worktrees(cx)
2739 .next_back()
2740 .and_then(|worktree| worktree.read(cx).root_entry())
2741 .map(|entry| entry.id);
2742
2743 let old_ancestors = std::mem::take(&mut self.ancestors);
2744 self.visible_entries.clear();
2745 let mut max_width_item = None;
2746 for worktree in project.visible_worktrees(cx) {
2747 let worktree_snapshot = worktree.read(cx).snapshot();
2748 let worktree_id = worktree_snapshot.id();
2749
2750 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2751 hash_map::Entry::Occupied(e) => e.into_mut(),
2752 hash_map::Entry::Vacant(e) => {
2753 // The first time a worktree's root entry becomes available,
2754 // mark that root entry as expanded.
2755 if let Some(entry) = worktree_snapshot.root_entry() {
2756 e.insert(vec![entry.id]).as_slice()
2757 } else {
2758 &[]
2759 }
2760 }
2761 };
2762
2763 let mut new_entry_parent_id = None;
2764 let mut new_entry_kind = EntryKind::Dir;
2765 if let Some(edit_state) = &self.edit_state {
2766 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2767 new_entry_parent_id = Some(edit_state.entry_id);
2768 new_entry_kind = if edit_state.is_dir {
2769 EntryKind::Dir
2770 } else {
2771 EntryKind::File
2772 };
2773 }
2774 }
2775
2776 let mut visible_worktree_entries = Vec::new();
2777 let mut entry_iter =
2778 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
2779 let mut auto_folded_ancestors = vec![];
2780 while let Some(entry) = entry_iter.entry() {
2781 if auto_collapse_dirs && entry.kind.is_dir() {
2782 auto_folded_ancestors.push(entry.id);
2783 if !self.unfolded_dir_ids.contains(&entry.id) {
2784 if let Some(root_path) = worktree_snapshot.root_entry() {
2785 let mut child_entries = worktree_snapshot.child_entries(&entry.path);
2786 if let Some(child) = child_entries.next() {
2787 if entry.path != root_path.path
2788 && child_entries.next().is_none()
2789 && child.kind.is_dir()
2790 {
2791 entry_iter.advance();
2792
2793 continue;
2794 }
2795 }
2796 }
2797 }
2798 let depth = old_ancestors
2799 .get(&entry.id)
2800 .map(|ancestor| ancestor.current_ancestor_depth)
2801 .unwrap_or_default()
2802 .min(auto_folded_ancestors.len());
2803 if let Some(edit_state) = &mut self.edit_state {
2804 if edit_state.entry_id == entry.id {
2805 edit_state.depth = depth;
2806 }
2807 }
2808 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2809 if ancestors.len() > 1 {
2810 ancestors.reverse();
2811 self.ancestors.insert(
2812 entry.id,
2813 FoldedAncestors {
2814 current_ancestor_depth: depth,
2815 ancestors,
2816 },
2817 );
2818 }
2819 }
2820 auto_folded_ancestors.clear();
2821 if !hide_gitignore || !entry.is_ignored {
2822 visible_worktree_entries.push(entry.to_owned());
2823 }
2824 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2825 entry.id == new_entry_id || {
2826 self.ancestors.get(&entry.id).map_or(false, |entries| {
2827 entries
2828 .ancestors
2829 .iter()
2830 .any(|entry_id| *entry_id == new_entry_id)
2831 })
2832 }
2833 } else {
2834 false
2835 };
2836 if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) {
2837 visible_worktree_entries.push(GitEntry {
2838 entry: Entry {
2839 id: NEW_ENTRY_ID,
2840 kind: new_entry_kind,
2841 path: entry.path.join("\0").into(),
2842 inode: 0,
2843 mtime: entry.mtime,
2844 size: entry.size,
2845 is_ignored: entry.is_ignored,
2846 is_external: false,
2847 is_private: false,
2848 is_always_included: entry.is_always_included,
2849 canonical_path: entry.canonical_path.clone(),
2850 char_bag: entry.char_bag,
2851 is_fifo: entry.is_fifo,
2852 },
2853 git_summary: entry.git_summary,
2854 });
2855 }
2856 let worktree_abs_path = worktree.read(cx).abs_path();
2857 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2858 let Some(path_name) = worktree_abs_path.file_name() else {
2859 continue;
2860 };
2861 let path = ArcCow::Borrowed(Path::new(path_name));
2862 let depth = 0;
2863 (depth, path)
2864 } else if entry.is_file() {
2865 let Some(path_name) = entry
2866 .path
2867 .file_name()
2868 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2869 .log_err()
2870 else {
2871 continue;
2872 };
2873 let path = ArcCow::Borrowed(Path::new(path_name));
2874 let depth = entry.path.ancestors().count() - 1;
2875 (depth, path)
2876 } else {
2877 let path = self
2878 .ancestors
2879 .get(&entry.id)
2880 .and_then(|ancestors| {
2881 let outermost_ancestor = ancestors.ancestors.last()?;
2882 let root_folded_entry = worktree
2883 .read(cx)
2884 .entry_for_id(*outermost_ancestor)?
2885 .path
2886 .as_ref();
2887 entry
2888 .path
2889 .strip_prefix(root_folded_entry)
2890 .ok()
2891 .and_then(|suffix| {
2892 let full_path = Path::new(root_folded_entry.file_name()?);
2893 Some(ArcCow::Owned(Arc::<Path>::from(full_path.join(suffix))))
2894 })
2895 })
2896 .or_else(|| entry.path.file_name().map(Path::new).map(ArcCow::Borrowed))
2897 .unwrap_or_else(|| ArcCow::Owned(entry.path.clone()));
2898 let depth = path.components().count();
2899 (depth, path)
2900 };
2901 let width_estimate = item_width_estimate(
2902 depth,
2903 path.to_string_lossy().chars().count(),
2904 entry.canonical_path.is_some(),
2905 );
2906
2907 match max_width_item.as_mut() {
2908 Some((id, worktree_id, width)) => {
2909 if *width < width_estimate {
2910 *id = entry.id;
2911 *worktree_id = worktree.read(cx).id();
2912 *width = width_estimate;
2913 }
2914 }
2915 None => {
2916 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2917 }
2918 }
2919
2920 if expanded_dir_ids.binary_search(&entry.id).is_err()
2921 && entry_iter.advance_to_sibling()
2922 {
2923 continue;
2924 }
2925 entry_iter.advance();
2926 }
2927
2928 project::sort_worktree_entries(&mut visible_worktree_entries);
2929
2930 self.visible_entries
2931 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2932 }
2933
2934 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2935 let mut visited_worktrees_length = 0;
2936 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2937 if worktree_id == *id {
2938 entries
2939 .iter()
2940 .position(|entry| entry.id == project_entry_id)
2941 } else {
2942 visited_worktrees_length += entries.len();
2943 None
2944 }
2945 });
2946 if let Some(index) = index {
2947 self.max_width_item_index = Some(visited_worktrees_length + index);
2948 }
2949 }
2950 if let Some((worktree_id, entry_id)) = new_selected_entry {
2951 self.selection = Some(SelectedEntry {
2952 worktree_id,
2953 entry_id,
2954 });
2955 }
2956 }
2957
2958 fn expand_entry(
2959 &mut self,
2960 worktree_id: WorktreeId,
2961 entry_id: ProjectEntryId,
2962 cx: &mut Context<Self>,
2963 ) {
2964 self.project.update(cx, |project, cx| {
2965 if let Some((worktree, expanded_dir_ids)) = project
2966 .worktree_for_id(worktree_id, cx)
2967 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2968 {
2969 project.expand_entry(worktree_id, entry_id, cx);
2970 let worktree = worktree.read(cx);
2971
2972 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2973 loop {
2974 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2975 expanded_dir_ids.insert(ix, entry.id);
2976 }
2977
2978 if let Some(parent_entry) =
2979 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2980 {
2981 entry = parent_entry;
2982 } else {
2983 break;
2984 }
2985 }
2986 }
2987 }
2988 });
2989 }
2990
2991 fn drop_external_files(
2992 &mut self,
2993 paths: &[PathBuf],
2994 entry_id: ProjectEntryId,
2995 window: &mut Window,
2996 cx: &mut Context<Self>,
2997 ) {
2998 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2999
3000 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
3001
3002 let Some((target_directory, worktree, fs)) = maybe!({
3003 let project = self.project.read(cx);
3004 let fs = project.fs().clone();
3005 let worktree = project.worktree_for_entry(entry_id, cx)?;
3006 let entry = worktree.read(cx).entry_for_id(entry_id)?;
3007 let path = entry.path.clone();
3008 let target_directory = if entry.is_dir() {
3009 path.to_path_buf()
3010 } else {
3011 path.parent()?.to_path_buf()
3012 };
3013 Some((target_directory, worktree, fs))
3014 }) else {
3015 return;
3016 };
3017
3018 let mut paths_to_replace = Vec::new();
3019 for path in &paths {
3020 if let Some(name) = path.file_name() {
3021 let mut target_path = target_directory.clone();
3022 target_path.push(name);
3023 if target_path.exists() {
3024 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
3025 }
3026 }
3027 }
3028
3029 cx.spawn_in(window, async move |this, cx| {
3030 async move {
3031 for (filename, original_path) in &paths_to_replace {
3032 let answer = cx.update(|window, cx| {
3033 window
3034 .prompt(
3035 PromptLevel::Info,
3036 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
3037 None,
3038 &["Replace", "Cancel"],
3039 cx,
3040 )
3041 })?.await?;
3042
3043 if answer == 1 {
3044 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
3045 paths.remove(item_idx);
3046 }
3047 }
3048 }
3049
3050 if paths.is_empty() {
3051 return Ok(());
3052 }
3053
3054 let task = worktree.update( cx, |worktree, cx| {
3055 worktree.copy_external_entries(target_directory.into(), paths, fs, cx)
3056 })?;
3057
3058 let opened_entries = task.await.with_context(|| "failed to copy external paths")?;
3059 this.update(cx, |this, cx| {
3060 if open_file_after_drop && !opened_entries.is_empty() {
3061 this.open_entry(opened_entries[0], true, false, cx);
3062 }
3063 })
3064 }
3065 .log_err().await
3066 })
3067 .detach();
3068 }
3069
3070 fn drag_onto(
3071 &mut self,
3072 selections: &DraggedSelection,
3073 target_entry_id: ProjectEntryId,
3074 is_file: bool,
3075 window: &mut Window,
3076 cx: &mut Context<Self>,
3077 ) {
3078 let should_copy = window.modifiers().alt;
3079 if should_copy {
3080 let _ = maybe!({
3081 let project = self.project.read(cx);
3082 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
3083 let worktree_id = target_worktree.read(cx).id();
3084 let target_entry = target_worktree
3085 .read(cx)
3086 .entry_for_id(target_entry_id)?
3087 .clone();
3088
3089 let mut copy_tasks = Vec::new();
3090 let mut disambiguation_range = None;
3091 for selection in selections.items() {
3092 let (new_path, new_disambiguation_range) = self.create_paste_path(
3093 selection,
3094 (target_worktree.clone(), &target_entry),
3095 cx,
3096 )?;
3097
3098 let task = self.project.update(cx, |project, cx| {
3099 project.copy_entry(selection.entry_id, None, new_path, cx)
3100 });
3101 copy_tasks.push(task);
3102 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3103 }
3104
3105 let item_count = copy_tasks.len();
3106
3107 cx.spawn_in(window, async move |project_panel, cx| {
3108 let mut last_succeed = None;
3109 for task in copy_tasks.into_iter() {
3110 if let Some(Some(entry)) = task.await.log_err() {
3111 last_succeed = Some(entry.id);
3112 }
3113 }
3114 // update selection
3115 if let Some(entry_id) = last_succeed {
3116 project_panel
3117 .update_in(cx, |project_panel, window, cx| {
3118 project_panel.selection = Some(SelectedEntry {
3119 worktree_id,
3120 entry_id,
3121 });
3122
3123 // if only one entry was dragged and it was disambiguated, open the rename editor
3124 if item_count == 1 && disambiguation_range.is_some() {
3125 project_panel.rename_impl(disambiguation_range, window, cx);
3126 }
3127 })
3128 .ok();
3129 }
3130 })
3131 .detach();
3132 Some(())
3133 });
3134 } else {
3135 for selection in selections.items() {
3136 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
3137 }
3138 }
3139 }
3140
3141 fn index_for_entry(
3142 &self,
3143 entry_id: ProjectEntryId,
3144 worktree_id: WorktreeId,
3145 ) -> Option<(usize, usize, usize)> {
3146 let mut worktree_ix = 0;
3147 let mut total_ix = 0;
3148 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3149 if worktree_id != *current_worktree_id {
3150 total_ix += visible_worktree_entries.len();
3151 worktree_ix += 1;
3152 continue;
3153 }
3154
3155 return visible_worktree_entries
3156 .iter()
3157 .enumerate()
3158 .find(|(_, entry)| entry.id == entry_id)
3159 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
3160 }
3161 None
3162 }
3163
3164 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
3165 let mut offset = 0;
3166 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3167 if visible_worktree_entries.len() > offset + index {
3168 return visible_worktree_entries
3169 .get(index)
3170 .map(|entry| (*worktree_id, entry.to_ref()));
3171 }
3172 offset += visible_worktree_entries.len();
3173 }
3174 None
3175 }
3176
3177 fn iter_visible_entries(
3178 &self,
3179 range: Range<usize>,
3180 window: &mut Window,
3181 cx: &mut Context<ProjectPanel>,
3182 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
3183 ) {
3184 let mut ix = 0;
3185 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3186 if ix >= range.end {
3187 return;
3188 }
3189
3190 if ix + visible_worktree_entries.len() <= range.start {
3191 ix += visible_worktree_entries.len();
3192 continue;
3193 }
3194
3195 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3196 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3197 let entries = entries_paths.get_or_init(|| {
3198 visible_worktree_entries
3199 .iter()
3200 .map(|e| (e.path.clone()))
3201 .collect()
3202 });
3203 for entry in visible_worktree_entries[entry_range].iter() {
3204 callback(&entry, entries, window, cx);
3205 }
3206 ix = end_ix;
3207 }
3208 }
3209
3210 fn for_each_visible_entry(
3211 &self,
3212 range: Range<usize>,
3213 window: &mut Window,
3214 cx: &mut Context<ProjectPanel>,
3215 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3216 ) {
3217 let mut ix = 0;
3218 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3219 if ix >= range.end {
3220 return;
3221 }
3222
3223 if ix + visible_worktree_entries.len() <= range.start {
3224 ix += visible_worktree_entries.len();
3225 continue;
3226 }
3227
3228 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3229 let (git_status_setting, show_file_icons, show_folder_icons) = {
3230 let settings = ProjectPanelSettings::get_global(cx);
3231 (
3232 settings.git_status,
3233 settings.file_icons,
3234 settings.folder_icons,
3235 )
3236 };
3237 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3238 let snapshot = worktree.read(cx).snapshot();
3239 let root_name = OsStr::new(snapshot.root_name());
3240 let expanded_entry_ids = self
3241 .expanded_dir_ids
3242 .get(&snapshot.id())
3243 .map(Vec::as_slice)
3244 .unwrap_or(&[]);
3245
3246 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3247 let entries = entries_paths.get_or_init(|| {
3248 visible_worktree_entries
3249 .iter()
3250 .map(|e| (e.path.clone()))
3251 .collect()
3252 });
3253 for entry in visible_worktree_entries[entry_range].iter() {
3254 let status = git_status_setting
3255 .then_some(entry.git_summary)
3256 .unwrap_or_default();
3257 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3258 let icon = match entry.kind {
3259 EntryKind::File => {
3260 if show_file_icons {
3261 FileIcons::get_icon(&entry.path, cx)
3262 } else {
3263 None
3264 }
3265 }
3266 _ => {
3267 if show_folder_icons {
3268 FileIcons::get_folder_icon(is_expanded, cx)
3269 } else {
3270 FileIcons::get_chevron_icon(is_expanded, cx)
3271 }
3272 }
3273 };
3274
3275 let (depth, difference) =
3276 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3277
3278 let filename = match difference {
3279 diff if diff > 1 => entry
3280 .path
3281 .iter()
3282 .skip(entry.path.components().count() - diff)
3283 .collect::<PathBuf>()
3284 .to_str()
3285 .unwrap_or_default()
3286 .to_string(),
3287 _ => entry
3288 .path
3289 .file_name()
3290 .map(|name| name.to_string_lossy().into_owned())
3291 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3292 };
3293 let selection = SelectedEntry {
3294 worktree_id: snapshot.id(),
3295 entry_id: entry.id,
3296 };
3297
3298 let is_marked = self.marked_entries.contains(&selection);
3299
3300 let diagnostic_severity = self
3301 .diagnostics
3302 .get(&(*worktree_id, entry.path.to_path_buf()))
3303 .cloned();
3304
3305 let filename_text_color =
3306 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3307
3308 let mut details = EntryDetails {
3309 filename,
3310 icon,
3311 path: entry.path.clone(),
3312 depth,
3313 kind: entry.kind,
3314 is_ignored: entry.is_ignored,
3315 is_expanded,
3316 is_selected: self.selection == Some(selection),
3317 is_marked,
3318 is_editing: false,
3319 is_processing: false,
3320 is_cut: self
3321 .clipboard
3322 .as_ref()
3323 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3324 filename_text_color,
3325 diagnostic_severity,
3326 git_status: status,
3327 is_private: entry.is_private,
3328 worktree_id: *worktree_id,
3329 canonical_path: entry.canonical_path.clone(),
3330 };
3331
3332 if let Some(edit_state) = &self.edit_state {
3333 let is_edited_entry = if edit_state.is_new_entry() {
3334 entry.id == NEW_ENTRY_ID
3335 } else {
3336 entry.id == edit_state.entry_id
3337 || self
3338 .ancestors
3339 .get(&entry.id)
3340 .is_some_and(|auto_folded_dirs| {
3341 auto_folded_dirs
3342 .ancestors
3343 .iter()
3344 .any(|entry_id| *entry_id == edit_state.entry_id)
3345 })
3346 };
3347
3348 if is_edited_entry {
3349 if let Some(processing_filename) = &edit_state.processing_filename {
3350 details.is_processing = true;
3351 if let Some(ancestors) = edit_state
3352 .leaf_entry_id
3353 .and_then(|entry| self.ancestors.get(&entry))
3354 {
3355 let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
3356 let all_components = ancestors.ancestors.len();
3357
3358 let prefix_components = all_components - position;
3359 let suffix_components = position.checked_sub(1);
3360 let mut previous_components =
3361 Path::new(&details.filename).components();
3362 let mut new_path = previous_components
3363 .by_ref()
3364 .take(prefix_components)
3365 .collect::<PathBuf>();
3366 if let Some(last_component) =
3367 Path::new(processing_filename).components().next_back()
3368 {
3369 new_path.push(last_component);
3370 previous_components.next();
3371 }
3372
3373 if let Some(_) = suffix_components {
3374 new_path.push(previous_components);
3375 }
3376 if let Some(str) = new_path.to_str() {
3377 details.filename.clear();
3378 details.filename.push_str(str);
3379 }
3380 } else {
3381 details.filename.clear();
3382 details.filename.push_str(processing_filename);
3383 }
3384 } else {
3385 if edit_state.is_new_entry() {
3386 details.filename.clear();
3387 }
3388 details.is_editing = true;
3389 }
3390 }
3391 }
3392
3393 callback(entry.id, details, window, cx);
3394 }
3395 }
3396 ix = end_ix;
3397 }
3398 }
3399
3400 fn find_entry_in_worktree(
3401 &self,
3402 worktree_id: WorktreeId,
3403 reverse_search: bool,
3404 only_visible_entries: bool,
3405 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3406 cx: &mut Context<Self>,
3407 ) -> Option<GitEntry> {
3408 if only_visible_entries {
3409 let entries = self
3410 .visible_entries
3411 .iter()
3412 .find_map(|(tree_id, entries, _)| {
3413 if worktree_id == *tree_id {
3414 Some(entries)
3415 } else {
3416 None
3417 }
3418 })?
3419 .clone();
3420
3421 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3422 .find(|ele| predicate(ele.to_ref(), worktree_id))
3423 .cloned();
3424 }
3425
3426 let repo_snapshots = self
3427 .project
3428 .read(cx)
3429 .git_store()
3430 .read(cx)
3431 .repo_snapshots(cx);
3432 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3433 worktree.update(cx, |tree, _| {
3434 utils::ReversibleIterable::new(
3435 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
3436 reverse_search,
3437 )
3438 .find_single_ended(|ele| predicate(*ele, worktree_id))
3439 .map(|ele| ele.to_owned())
3440 })
3441 }
3442
3443 fn find_entry(
3444 &self,
3445 start: Option<&SelectedEntry>,
3446 reverse_search: bool,
3447 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3448 cx: &mut Context<Self>,
3449 ) -> Option<SelectedEntry> {
3450 let mut worktree_ids: Vec<_> = self
3451 .visible_entries
3452 .iter()
3453 .map(|(worktree_id, _, _)| *worktree_id)
3454 .collect();
3455 let repo_snapshots = self
3456 .project
3457 .read(cx)
3458 .git_store()
3459 .read(cx)
3460 .repo_snapshots(cx);
3461
3462 let mut last_found: Option<SelectedEntry> = None;
3463
3464 if let Some(start) = start {
3465 let worktree = self
3466 .project
3467 .read(cx)
3468 .worktree_for_id(start.worktree_id, cx)?;
3469
3470 let search = worktree.update(cx, |tree, _| {
3471 let entry = tree.entry_for_id(start.entry_id)?;
3472 let root_entry = tree.root_entry()?;
3473 let tree_id = tree.id();
3474
3475 let mut first_iter = GitTraversal::new(
3476 &repo_snapshots,
3477 tree.traverse_from_path(true, true, true, entry.path.as_ref()),
3478 );
3479
3480 if reverse_search {
3481 first_iter.next();
3482 }
3483
3484 let first = first_iter
3485 .enumerate()
3486 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3487 .map(|(_, entry)| entry)
3488 .find(|ele| predicate(*ele, tree_id))
3489 .map(|ele| ele.to_owned());
3490
3491 let second_iter = GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize));
3492
3493 let second = if reverse_search {
3494 second_iter
3495 .take_until(|ele| ele.id == start.entry_id)
3496 .filter(|ele| predicate(*ele, tree_id))
3497 .last()
3498 .map(|ele| ele.to_owned())
3499 } else {
3500 second_iter
3501 .take_while(|ele| ele.id != start.entry_id)
3502 .filter(|ele| predicate(*ele, tree_id))
3503 .last()
3504 .map(|ele| ele.to_owned())
3505 };
3506
3507 if reverse_search {
3508 Some((second, first))
3509 } else {
3510 Some((first, second))
3511 }
3512 });
3513
3514 if let Some((first, second)) = search {
3515 let first = first.map(|entry| SelectedEntry {
3516 worktree_id: start.worktree_id,
3517 entry_id: entry.id,
3518 });
3519
3520 let second = second.map(|entry| SelectedEntry {
3521 worktree_id: start.worktree_id,
3522 entry_id: entry.id,
3523 });
3524
3525 if first.is_some() {
3526 return first;
3527 }
3528 last_found = second;
3529
3530 let idx = worktree_ids
3531 .iter()
3532 .enumerate()
3533 .find(|(_, ele)| **ele == start.worktree_id)
3534 .map(|(idx, _)| idx);
3535
3536 if let Some(idx) = idx {
3537 worktree_ids.rotate_left(idx + 1usize);
3538 worktree_ids.pop();
3539 }
3540 }
3541 }
3542
3543 for tree_id in worktree_ids.into_iter() {
3544 if let Some(found) =
3545 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3546 {
3547 return Some(SelectedEntry {
3548 worktree_id: tree_id,
3549 entry_id: found.id,
3550 });
3551 }
3552 }
3553
3554 last_found
3555 }
3556
3557 fn find_visible_entry(
3558 &self,
3559 start: Option<&SelectedEntry>,
3560 reverse_search: bool,
3561 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3562 cx: &mut Context<Self>,
3563 ) -> Option<SelectedEntry> {
3564 let mut worktree_ids: Vec<_> = self
3565 .visible_entries
3566 .iter()
3567 .map(|(worktree_id, _, _)| *worktree_id)
3568 .collect();
3569
3570 let mut last_found: Option<SelectedEntry> = None;
3571
3572 if let Some(start) = start {
3573 let entries = self
3574 .visible_entries
3575 .iter()
3576 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3577 .map(|(_, entries, _)| entries)?;
3578
3579 let mut start_idx = entries
3580 .iter()
3581 .enumerate()
3582 .find(|(_, ele)| ele.id == start.entry_id)
3583 .map(|(idx, _)| idx)?;
3584
3585 if reverse_search {
3586 start_idx = start_idx.saturating_add(1usize);
3587 }
3588
3589 let (left, right) = entries.split_at_checked(start_idx)?;
3590
3591 let (first_iter, second_iter) = if reverse_search {
3592 (
3593 utils::ReversibleIterable::new(left.iter(), reverse_search),
3594 utils::ReversibleIterable::new(right.iter(), reverse_search),
3595 )
3596 } else {
3597 (
3598 utils::ReversibleIterable::new(right.iter(), reverse_search),
3599 utils::ReversibleIterable::new(left.iter(), reverse_search),
3600 )
3601 };
3602
3603 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3604 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3605
3606 if first_search.is_some() {
3607 return first_search.map(|entry| SelectedEntry {
3608 worktree_id: start.worktree_id,
3609 entry_id: entry.id,
3610 });
3611 }
3612
3613 last_found = second_search.map(|entry| SelectedEntry {
3614 worktree_id: start.worktree_id,
3615 entry_id: entry.id,
3616 });
3617
3618 let idx = worktree_ids
3619 .iter()
3620 .enumerate()
3621 .find(|(_, ele)| **ele == start.worktree_id)
3622 .map(|(idx, _)| idx);
3623
3624 if let Some(idx) = idx {
3625 worktree_ids.rotate_left(idx + 1usize);
3626 worktree_ids.pop();
3627 }
3628 }
3629
3630 for tree_id in worktree_ids.into_iter() {
3631 if let Some(found) =
3632 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3633 {
3634 return Some(SelectedEntry {
3635 worktree_id: tree_id,
3636 entry_id: found.id,
3637 });
3638 }
3639 }
3640
3641 last_found
3642 }
3643
3644 fn calculate_depth_and_difference(
3645 entry: &Entry,
3646 visible_worktree_entries: &HashSet<Arc<Path>>,
3647 ) -> (usize, usize) {
3648 let (depth, difference) = entry
3649 .path
3650 .ancestors()
3651 .skip(1) // Skip the entry itself
3652 .find_map(|ancestor| {
3653 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3654 let entry_path_components_count = entry.path.components().count();
3655 let parent_path_components_count = parent_entry.components().count();
3656 let difference = entry_path_components_count - parent_path_components_count;
3657 let depth = parent_entry
3658 .ancestors()
3659 .skip(1)
3660 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3661 .count();
3662 Some((depth + 1, difference))
3663 } else {
3664 None
3665 }
3666 })
3667 .unwrap_or((0, 0));
3668
3669 (depth, difference)
3670 }
3671
3672 fn render_entry(
3673 &self,
3674 entry_id: ProjectEntryId,
3675 details: EntryDetails,
3676 window: &mut Window,
3677 cx: &mut Context<Self>,
3678 ) -> Stateful<Div> {
3679 const GROUP_NAME: &str = "project_entry";
3680
3681 let kind = details.kind;
3682 let settings = ProjectPanelSettings::get_global(cx);
3683 let show_editor = details.is_editing && !details.is_processing;
3684
3685 let selection = SelectedEntry {
3686 worktree_id: details.worktree_id,
3687 entry_id,
3688 };
3689
3690 let is_marked = self.marked_entries.contains(&selection);
3691 let is_active = self
3692 .selection
3693 .map_or(false, |selection| selection.entry_id == entry_id);
3694
3695 let file_name = details.filename.clone();
3696
3697 let mut icon = details.icon.clone();
3698 if settings.file_icons && show_editor && details.kind.is_file() {
3699 let filename = self.filename_editor.read(cx).text(cx);
3700 if filename.len() > 2 {
3701 icon = FileIcons::get_icon(Path::new(&filename), cx);
3702 }
3703 }
3704
3705 let filename_text_color = details.filename_text_color;
3706 let diagnostic_severity = details.diagnostic_severity;
3707 let item_colors = get_item_color(cx);
3708
3709 let canonical_path = details
3710 .canonical_path
3711 .as_ref()
3712 .map(|f| f.to_string_lossy().to_string());
3713 let path = details.path.clone();
3714
3715 let depth = details.depth;
3716 let worktree_id = details.worktree_id;
3717 let selections = Arc::new(self.marked_entries.clone());
3718
3719 let dragged_selection = DraggedSelection {
3720 active_selection: selection,
3721 marked_selections: selections,
3722 };
3723
3724 let bg_color = if is_marked {
3725 item_colors.marked
3726 } else {
3727 item_colors.default
3728 };
3729
3730 let bg_hover_color = if is_marked {
3731 item_colors.marked
3732 } else {
3733 item_colors.hover
3734 };
3735
3736 let validation_color_and_message = if show_editor {
3737 match self
3738 .edit_state
3739 .as_ref()
3740 .map_or(ValidationState::None, |e| e.validation_state.clone())
3741 {
3742 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg.clone())),
3743 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg.clone())),
3744 ValidationState::None => None,
3745 }
3746 } else {
3747 None
3748 };
3749
3750 let border_color =
3751 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3752 match validation_color_and_message {
3753 Some((color, _)) => color,
3754 None => item_colors.focused,
3755 }
3756 } else {
3757 bg_color
3758 };
3759
3760 let border_hover_color =
3761 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3762 match validation_color_and_message {
3763 Some((color, _)) => color,
3764 None => item_colors.focused,
3765 }
3766 } else {
3767 bg_hover_color
3768 };
3769
3770 let folded_directory_drag_target = self.folded_directory_drag_target;
3771
3772 div()
3773 .id(entry_id.to_proto() as usize)
3774 .group(GROUP_NAME)
3775 .cursor_pointer()
3776 .rounded_none()
3777 .bg(bg_color)
3778 .border_1()
3779 .border_r_2()
3780 .border_color(border_color)
3781 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3782 .on_drag_move::<ExternalPaths>(cx.listener(
3783 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
3784 if event.bounds.contains(&event.event.position) {
3785 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3786 return;
3787 }
3788 this.last_external_paths_drag_over_entry = Some(entry_id);
3789 this.marked_entries.clear();
3790
3791 let Some((worktree, path, entry)) = maybe!({
3792 let worktree = this
3793 .project
3794 .read(cx)
3795 .worktree_for_id(selection.worktree_id, cx)?;
3796 let worktree = worktree.read(cx);
3797 let entry = worktree.entry_for_path(&path)?;
3798 let path = if entry.is_dir() {
3799 path.as_ref()
3800 } else {
3801 path.parent()?
3802 };
3803 Some((worktree, path, entry))
3804 }) else {
3805 return;
3806 };
3807
3808 this.marked_entries.insert(SelectedEntry {
3809 entry_id: entry.id,
3810 worktree_id: worktree.id(),
3811 });
3812
3813 for entry in worktree.child_entries(path) {
3814 this.marked_entries.insert(SelectedEntry {
3815 entry_id: entry.id,
3816 worktree_id: worktree.id(),
3817 });
3818 }
3819
3820 cx.notify();
3821 }
3822 },
3823 ))
3824 .on_drop(cx.listener(
3825 move |this, external_paths: &ExternalPaths, window, cx| {
3826 this.hover_scroll_task.take();
3827 this.last_external_paths_drag_over_entry = None;
3828 this.marked_entries.clear();
3829 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3830 cx.stop_propagation();
3831 },
3832 ))
3833 .on_drag_move::<DraggedSelection>(cx.listener(
3834 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3835 if event.bounds.contains(&event.event.position) {
3836 if this.last_selection_drag_over_entry == Some(entry_id) {
3837 return;
3838 }
3839 this.last_selection_drag_over_entry = Some(entry_id);
3840 this.hover_expand_task.take();
3841
3842 if !kind.is_dir()
3843 || this
3844 .expanded_dir_ids
3845 .get(&details.worktree_id)
3846 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3847 {
3848 return;
3849 }
3850
3851 let bounds = event.bounds;
3852 this.hover_expand_task =
3853 Some(cx.spawn_in(window, async move |this, cx| {
3854 cx.background_executor()
3855 .timer(Duration::from_millis(500))
3856 .await;
3857 this.update_in(cx, |this, window, cx| {
3858 this.hover_expand_task.take();
3859 if this.last_selection_drag_over_entry == Some(entry_id)
3860 && bounds.contains(&window.mouse_position())
3861 {
3862 this.expand_entry(worktree_id, entry_id, cx);
3863 this.update_visible_entries(
3864 Some((worktree_id, entry_id)),
3865 cx,
3866 );
3867 cx.notify();
3868 }
3869 })
3870 .ok();
3871 }));
3872 }
3873 },
3874 ))
3875 .on_drag(
3876 dragged_selection,
3877 move |selection, click_offset, _window, cx| {
3878 cx.new(|_| DraggedProjectEntryView {
3879 details: details.clone(),
3880 click_offset,
3881 selection: selection.active_selection,
3882 selections: selection.marked_selections.clone(),
3883 })
3884 },
3885 )
3886 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3887 if folded_directory_drag_target.is_some() {
3888 return style;
3889 }
3890 style.bg(item_colors.drag_over)
3891 })
3892 .on_drop(
3893 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3894 this.hover_scroll_task.take();
3895 this.hover_expand_task.take();
3896 if folded_directory_drag_target.is_some() {
3897 return;
3898 }
3899 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3900 }),
3901 )
3902 .on_mouse_down(
3903 MouseButton::Left,
3904 cx.listener(move |this, _, _, cx| {
3905 this.mouse_down = true;
3906 cx.propagate();
3907 }),
3908 )
3909 .on_click(
3910 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3911 if event.down.button == MouseButton::Right
3912 || event.down.first_mouse
3913 || show_editor
3914 {
3915 return;
3916 }
3917 if event.down.button == MouseButton::Left {
3918 this.mouse_down = false;
3919 }
3920 cx.stop_propagation();
3921
3922 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3923 let current_selection = this.index_for_selection(selection);
3924 let clicked_entry = SelectedEntry {
3925 entry_id,
3926 worktree_id,
3927 };
3928 let target_selection = this.index_for_selection(clicked_entry);
3929 if let Some(((_, _, source_index), (_, _, target_index))) =
3930 current_selection.zip(target_selection)
3931 {
3932 let range_start = source_index.min(target_index);
3933 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3934 let mut new_selections = BTreeSet::new();
3935 this.for_each_visible_entry(
3936 range_start..range_end,
3937 window,
3938 cx,
3939 |entry_id, details, _, _| {
3940 new_selections.insert(SelectedEntry {
3941 entry_id,
3942 worktree_id: details.worktree_id,
3943 });
3944 },
3945 );
3946
3947 this.marked_entries = this
3948 .marked_entries
3949 .union(&new_selections)
3950 .cloned()
3951 .collect();
3952
3953 this.selection = Some(clicked_entry);
3954 this.marked_entries.insert(clicked_entry);
3955 }
3956 } else if event.modifiers().secondary() {
3957 if event.down.click_count > 1 {
3958 this.split_entry(entry_id, cx);
3959 } else {
3960 this.selection = Some(selection);
3961 if !this.marked_entries.insert(selection) {
3962 this.marked_entries.remove(&selection);
3963 }
3964 }
3965 } else if kind.is_dir() {
3966 this.marked_entries.clear();
3967 if event.modifiers().alt {
3968 this.toggle_expand_all(entry_id, window, cx);
3969 } else {
3970 this.toggle_expanded(entry_id, window, cx);
3971 }
3972 } else {
3973 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3974 let click_count = event.up.click_count;
3975 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3976 let allow_preview = preview_tabs_enabled && click_count == 1;
3977 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3978 }
3979 }),
3980 )
3981 .child(
3982 ListItem::new(entry_id.to_proto() as usize)
3983 .indent_level(depth)
3984 .indent_step_size(px(settings.indent_size))
3985 .spacing(match settings.entry_spacing {
3986 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3987 project_panel_settings::EntrySpacing::Standard => {
3988 ListItemSpacing::ExtraDense
3989 }
3990 })
3991 .selectable(false)
3992 .when_some(canonical_path, |this, path| {
3993 this.end_slot::<AnyElement>(
3994 div()
3995 .id("symlink_icon")
3996 .pr_3()
3997 .tooltip(move |window, cx| {
3998 Tooltip::with_meta(
3999 path.to_string(),
4000 None,
4001 "Symbolic Link",
4002 window,
4003 cx,
4004 )
4005 })
4006 .child(
4007 Icon::new(IconName::ArrowUpRight)
4008 .size(IconSize::Indicator)
4009 .color(filename_text_color),
4010 )
4011 .into_any_element(),
4012 )
4013 })
4014 .child(if let Some(icon) = &icon {
4015 if let Some((_, decoration_color)) =
4016 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
4017 {
4018 let is_warning = diagnostic_severity
4019 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
4020 .unwrap_or(false);
4021 div().child(
4022 DecoratedIcon::new(
4023 Icon::from_path(icon.clone()).color(Color::Muted),
4024 Some(
4025 IconDecoration::new(
4026 if kind.is_file() {
4027 if is_warning {
4028 IconDecorationKind::Triangle
4029 } else {
4030 IconDecorationKind::X
4031 }
4032 } else {
4033 IconDecorationKind::Dot
4034 },
4035 bg_color,
4036 cx,
4037 )
4038 .group_name(Some(GROUP_NAME.into()))
4039 .knockout_hover_color(bg_hover_color)
4040 .color(decoration_color.color(cx))
4041 .position(Point {
4042 x: px(-2.),
4043 y: px(-2.),
4044 }),
4045 ),
4046 )
4047 .into_any_element(),
4048 )
4049 } else {
4050 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
4051 }
4052 } else {
4053 if let Some((icon_name, color)) =
4054 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
4055 {
4056 h_flex()
4057 .size(IconSize::default().rems())
4058 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
4059 } else {
4060 h_flex()
4061 .size(IconSize::default().rems())
4062 .invisible()
4063 .flex_none()
4064 }
4065 })
4066 .child(
4067 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
4068 h_flex().h_6().w_full().child(editor.clone())
4069 } else {
4070 h_flex().h_6().map(|mut this| {
4071 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
4072 let components = Path::new(&file_name)
4073 .components()
4074 .map(|comp| {
4075 let comp_str =
4076 comp.as_os_str().to_string_lossy().into_owned();
4077 comp_str
4078 })
4079 .collect::<Vec<_>>();
4080
4081 let components_len = components.len();
4082 let active_index = components_len
4083 - 1
4084 - folded_ancestors.current_ancestor_depth;
4085 const DELIMITER: SharedString =
4086 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
4087 for (index, component) in components.into_iter().enumerate() {
4088 if index != 0 {
4089 let delimiter_target_index = index - 1;
4090 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
4091 this = this.child(
4092 div()
4093 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
4094 this.hover_scroll_task.take();
4095 this.folded_directory_drag_target = None;
4096 if let Some(target_entry_id) = target_entry_id {
4097 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4098 }
4099 }))
4100 .on_drag_move(cx.listener(
4101 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4102 if event.bounds.contains(&event.event.position) {
4103 this.folded_directory_drag_target = Some(
4104 FoldedDirectoryDragTarget {
4105 entry_id,
4106 index: delimiter_target_index,
4107 is_delimiter_target: true,
4108 }
4109 );
4110 } else {
4111 let is_current_target = this.folded_directory_drag_target
4112 .map_or(false, |target|
4113 target.entry_id == entry_id &&
4114 target.index == delimiter_target_index &&
4115 target.is_delimiter_target
4116 );
4117 if is_current_target {
4118 this.folded_directory_drag_target = None;
4119 }
4120 }
4121
4122 },
4123 ))
4124 .child(
4125 Label::new(DELIMITER.clone())
4126 .single_line()
4127 .color(filename_text_color)
4128 )
4129 );
4130 }
4131 let id = SharedString::from(format!(
4132 "project_panel_path_component_{}_{index}",
4133 entry_id.to_usize()
4134 ));
4135 let label = div()
4136 .id(id)
4137 .on_click(cx.listener(move |this, _, _, cx| {
4138 if index != active_index {
4139 if let Some(folds) =
4140 this.ancestors.get_mut(&entry_id)
4141 {
4142 folds.current_ancestor_depth =
4143 components_len - 1 - index;
4144 cx.notify();
4145 }
4146 }
4147 }))
4148 .when(index != components_len - 1, |div|{
4149 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
4150 div
4151 .on_drag_move(cx.listener(
4152 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4153 if event.bounds.contains(&event.event.position) {
4154 this.folded_directory_drag_target = Some(
4155 FoldedDirectoryDragTarget {
4156 entry_id,
4157 index,
4158 is_delimiter_target: false,
4159 }
4160 );
4161 } else {
4162 let is_current_target = this.folded_directory_drag_target
4163 .as_ref()
4164 .map_or(false, |target|
4165 target.entry_id == entry_id &&
4166 target.index == index &&
4167 !target.is_delimiter_target
4168 );
4169 if is_current_target {
4170 this.folded_directory_drag_target = None;
4171 }
4172 }
4173 },
4174 ))
4175 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
4176 this.hover_scroll_task.take();
4177 this.folded_directory_drag_target = None;
4178 if let Some(target_entry_id) = target_entry_id {
4179 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4180 }
4181 }))
4182 .when(folded_directory_drag_target.map_or(false, |target|
4183 target.entry_id == entry_id &&
4184 target.index == index
4185 ), |this| {
4186 this.bg(item_colors.drag_over)
4187 })
4188 })
4189 .child(
4190 Label::new(component)
4191 .single_line()
4192 .color(filename_text_color)
4193 .when(
4194 index == active_index
4195 && (is_active || is_marked),
4196 |this| this.underline(),
4197 ),
4198 );
4199
4200 this = this.child(label);
4201 }
4202
4203 this
4204 } else {
4205 this.child(
4206 Label::new(file_name)
4207 .single_line()
4208 .color(filename_text_color),
4209 )
4210 }
4211 })
4212 }
4213 .ml_1(),
4214 )
4215 .on_secondary_mouse_down(cx.listener(
4216 move |this, event: &MouseDownEvent, window, cx| {
4217 // Stop propagation to prevent the catch-all context menu for the project
4218 // panel from being deployed.
4219 cx.stop_propagation();
4220 // Some context menu actions apply to all marked entries. If the user
4221 // right-clicks on an entry that is not marked, they may not realize the
4222 // action applies to multiple entries. To avoid inadvertent changes, all
4223 // entries are unmarked.
4224 if !this.marked_entries.contains(&selection) {
4225 this.marked_entries.clear();
4226 }
4227 this.deploy_context_menu(event.position, entry_id, window, cx);
4228 },
4229 ))
4230 .overflow_x(),
4231 )
4232 .when_some(
4233 validation_color_and_message,
4234 |this, (color, message)| {
4235 this
4236 .relative()
4237 .child(
4238 deferred(
4239 div()
4240 .occlude()
4241 .absolute()
4242 .top_full()
4243 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
4244 .right(px(-0.5))
4245 .py_1()
4246 .px_2()
4247 .border_1()
4248 .border_color(color)
4249 .bg(cx.theme().colors().background)
4250 .child(
4251 Label::new(message)
4252 .color(Color::from(color))
4253 .size(LabelSize::Small)
4254 )
4255 )
4256 )
4257 }
4258 )
4259 }
4260
4261 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4262 if !Self::should_show_scrollbar(cx)
4263 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4264 {
4265 return None;
4266 }
4267 Some(
4268 div()
4269 .occlude()
4270 .id("project-panel-vertical-scroll")
4271 .on_mouse_move(cx.listener(|_, _, _, cx| {
4272 cx.notify();
4273 cx.stop_propagation()
4274 }))
4275 .on_hover(|_, _, cx| {
4276 cx.stop_propagation();
4277 })
4278 .on_any_mouse_down(|_, _, cx| {
4279 cx.stop_propagation();
4280 })
4281 .on_mouse_up(
4282 MouseButton::Left,
4283 cx.listener(|this, _, window, cx| {
4284 if !this.vertical_scrollbar_state.is_dragging()
4285 && !this.focus_handle.contains_focused(window, cx)
4286 {
4287 this.hide_scrollbar(window, cx);
4288 cx.notify();
4289 }
4290
4291 cx.stop_propagation();
4292 }),
4293 )
4294 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4295 cx.notify();
4296 }))
4297 .h_full()
4298 .absolute()
4299 .right_1()
4300 .top_1()
4301 .bottom_1()
4302 .w(px(12.))
4303 .cursor_default()
4304 .children(Scrollbar::vertical(
4305 // percentage as f32..end_offset as f32,
4306 self.vertical_scrollbar_state.clone(),
4307 )),
4308 )
4309 }
4310
4311 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4312 if !Self::should_show_scrollbar(cx)
4313 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4314 {
4315 return None;
4316 }
4317
4318 let scroll_handle = self.scroll_handle.0.borrow();
4319 let longest_item_width = scroll_handle
4320 .last_item_size
4321 .filter(|size| size.contents.width > size.item.width)?
4322 .contents
4323 .width
4324 .0 as f64;
4325 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4326 return None;
4327 }
4328
4329 Some(
4330 div()
4331 .occlude()
4332 .id("project-panel-horizontal-scroll")
4333 .on_mouse_move(cx.listener(|_, _, _, cx| {
4334 cx.notify();
4335 cx.stop_propagation()
4336 }))
4337 .on_hover(|_, _, cx| {
4338 cx.stop_propagation();
4339 })
4340 .on_any_mouse_down(|_, _, cx| {
4341 cx.stop_propagation();
4342 })
4343 .on_mouse_up(
4344 MouseButton::Left,
4345 cx.listener(|this, _, window, cx| {
4346 if !this.horizontal_scrollbar_state.is_dragging()
4347 && !this.focus_handle.contains_focused(window, cx)
4348 {
4349 this.hide_scrollbar(window, cx);
4350 cx.notify();
4351 }
4352
4353 cx.stop_propagation();
4354 }),
4355 )
4356 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4357 cx.notify();
4358 }))
4359 .w_full()
4360 .absolute()
4361 .right_1()
4362 .left_1()
4363 .bottom_1()
4364 .h(px(12.))
4365 .cursor_default()
4366 .when(self.width.is_some(), |this| {
4367 this.children(Scrollbar::horizontal(
4368 self.horizontal_scrollbar_state.clone(),
4369 ))
4370 }),
4371 )
4372 }
4373
4374 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4375 let mut dispatch_context = KeyContext::new_with_defaults();
4376 dispatch_context.add("ProjectPanel");
4377 dispatch_context.add("menu");
4378
4379 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4380 "editing"
4381 } else {
4382 "not_editing"
4383 };
4384
4385 dispatch_context.add(identifier);
4386 dispatch_context
4387 }
4388
4389 fn should_show_scrollbar(cx: &App) -> bool {
4390 let show = ProjectPanelSettings::get_global(cx)
4391 .scrollbar
4392 .show
4393 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4394 match show {
4395 ShowScrollbar::Auto => true,
4396 ShowScrollbar::System => true,
4397 ShowScrollbar::Always => true,
4398 ShowScrollbar::Never => false,
4399 }
4400 }
4401
4402 fn should_autohide_scrollbar(cx: &App) -> bool {
4403 let show = ProjectPanelSettings::get_global(cx)
4404 .scrollbar
4405 .show
4406 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4407 match show {
4408 ShowScrollbar::Auto => true,
4409 ShowScrollbar::System => cx
4410 .try_global::<ScrollbarAutoHide>()
4411 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4412 ShowScrollbar::Always => false,
4413 ShowScrollbar::Never => true,
4414 }
4415 }
4416
4417 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4418 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4419 if !Self::should_autohide_scrollbar(cx) {
4420 return;
4421 }
4422 self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
4423 cx.background_executor()
4424 .timer(SCROLLBAR_SHOW_INTERVAL)
4425 .await;
4426 panel
4427 .update(cx, |panel, cx| {
4428 panel.show_scrollbar = false;
4429 cx.notify();
4430 })
4431 .log_err();
4432 }))
4433 }
4434
4435 fn reveal_entry(
4436 &mut self,
4437 project: Entity<Project>,
4438 entry_id: ProjectEntryId,
4439 skip_ignored: bool,
4440 cx: &mut Context<Self>,
4441 ) -> Result<()> {
4442 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4443 let worktree = worktree.read(cx);
4444 if skip_ignored
4445 && worktree
4446 .entry_for_id(entry_id)
4447 .map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
4448 {
4449 return Err(anyhow!(
4450 "can't reveal an ignored entry in the project panel"
4451 ));
4452 }
4453
4454 let worktree_id = worktree.id();
4455 self.expand_entry(worktree_id, entry_id, cx);
4456 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4457 self.marked_entries.clear();
4458 self.marked_entries.insert(SelectedEntry {
4459 worktree_id,
4460 entry_id,
4461 });
4462 self.autoscroll(cx);
4463 cx.notify();
4464 Ok(())
4465 } else {
4466 Err(anyhow!(
4467 "can't reveal a non-existent entry in the project panel"
4468 ))
4469 }
4470 }
4471
4472 fn find_active_indent_guide(
4473 &self,
4474 indent_guides: &[IndentGuideLayout],
4475 cx: &App,
4476 ) -> Option<usize> {
4477 let (worktree, entry) = self.selected_entry(cx)?;
4478
4479 // Find the parent entry of the indent guide, this will either be the
4480 // expanded folder we have selected, or the parent of the currently
4481 // selected file/collapsed directory
4482 let mut entry = entry;
4483 loop {
4484 let is_expanded_dir = entry.is_dir()
4485 && self
4486 .expanded_dir_ids
4487 .get(&worktree.id())
4488 .map(|ids| ids.binary_search(&entry.id).is_ok())
4489 .unwrap_or(false);
4490 if is_expanded_dir {
4491 break;
4492 }
4493 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4494 }
4495
4496 let (active_indent_range, depth) = {
4497 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4498 let child_paths = &self.visible_entries[worktree_ix].1;
4499 let mut child_count = 0;
4500 let depth = entry.path.ancestors().count();
4501 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4502 if entry.path.ancestors().count() <= depth {
4503 break;
4504 }
4505 child_count += 1;
4506 }
4507
4508 let start = ix + 1;
4509 let end = start + child_count;
4510
4511 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4512 let visible_worktree_entries =
4513 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4514
4515 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4516 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4517 (start..end, depth)
4518 };
4519
4520 let candidates = indent_guides
4521 .iter()
4522 .enumerate()
4523 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4524
4525 for (i, indent) in candidates {
4526 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4527 if active_indent_range.start <= indent.offset.y + indent.length
4528 && indent.offset.y <= active_indent_range.end
4529 {
4530 return Some(i);
4531 }
4532 }
4533 None
4534 }
4535}
4536
4537fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4538 const ICON_SIZE_FACTOR: usize = 2;
4539 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4540 if is_symlink {
4541 item_width += ICON_SIZE_FACTOR;
4542 }
4543 item_width
4544}
4545
4546impl Render for ProjectPanel {
4547 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4548 let has_worktree = !self.visible_entries.is_empty();
4549 let project = self.project.read(cx);
4550 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4551 let show_indent_guides =
4552 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4553 let is_local = project.is_local();
4554
4555 if has_worktree {
4556 let item_count = self
4557 .visible_entries
4558 .iter()
4559 .map(|(_, worktree_entries, _)| worktree_entries.len())
4560 .sum();
4561
4562 fn handle_drag_move_scroll<T: 'static>(
4563 this: &mut ProjectPanel,
4564 e: &DragMoveEvent<T>,
4565 window: &mut Window,
4566 cx: &mut Context<ProjectPanel>,
4567 ) {
4568 if !e.bounds.contains(&e.event.position) {
4569 return;
4570 }
4571 this.hover_scroll_task.take();
4572 let panel_height = e.bounds.size.height;
4573 if panel_height <= px(0.) {
4574 return;
4575 }
4576
4577 let event_offset = e.event.position.y - e.bounds.origin.y;
4578 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4579 let hovered_region_offset = event_offset / panel_height;
4580
4581 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4582 // These pixels offsets were picked arbitrarily.
4583 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4584 8.
4585 } else if hovered_region_offset <= 0.15 {
4586 5.
4587 } else if hovered_region_offset >= 0.95 {
4588 -8.
4589 } else if hovered_region_offset >= 0.85 {
4590 -5.
4591 } else {
4592 return;
4593 };
4594 let adjustment = point(px(0.), px(vertical_scroll_offset));
4595 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
4596 loop {
4597 let should_stop_scrolling = this
4598 .update(cx, |this, cx| {
4599 this.hover_scroll_task.as_ref()?;
4600 let handle = this.scroll_handle.0.borrow_mut();
4601 let offset = handle.base_handle.offset();
4602
4603 handle.base_handle.set_offset(offset + adjustment);
4604 cx.notify();
4605 Some(())
4606 })
4607 .ok()
4608 .flatten()
4609 .is_some();
4610 if should_stop_scrolling {
4611 return;
4612 }
4613 cx.background_executor()
4614 .timer(Duration::from_millis(16))
4615 .await;
4616 }
4617 }));
4618 }
4619 h_flex()
4620 .id("project-panel")
4621 .group("project-panel")
4622 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4623 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4624 .size_full()
4625 .relative()
4626 .on_hover(cx.listener(|this, hovered, window, cx| {
4627 if *hovered {
4628 this.show_scrollbar = true;
4629 this.hide_scrollbar_task.take();
4630 cx.notify();
4631 } else if !this.focus_handle.contains_focused(window, cx) {
4632 this.hide_scrollbar(window, cx);
4633 }
4634 }))
4635 .on_click(cx.listener(|this, _event, _, cx| {
4636 cx.stop_propagation();
4637 this.selection = None;
4638 this.marked_entries.clear();
4639 }))
4640 .key_context(self.dispatch_context(window, cx))
4641 .on_action(cx.listener(Self::select_next))
4642 .on_action(cx.listener(Self::select_previous))
4643 .on_action(cx.listener(Self::select_first))
4644 .on_action(cx.listener(Self::select_last))
4645 .on_action(cx.listener(Self::select_parent))
4646 .on_action(cx.listener(Self::select_next_git_entry))
4647 .on_action(cx.listener(Self::select_prev_git_entry))
4648 .on_action(cx.listener(Self::select_next_diagnostic))
4649 .on_action(cx.listener(Self::select_prev_diagnostic))
4650 .on_action(cx.listener(Self::select_next_directory))
4651 .on_action(cx.listener(Self::select_prev_directory))
4652 .on_action(cx.listener(Self::expand_selected_entry))
4653 .on_action(cx.listener(Self::collapse_selected_entry))
4654 .on_action(cx.listener(Self::collapse_all_entries))
4655 .on_action(cx.listener(Self::open))
4656 .on_action(cx.listener(Self::open_permanent))
4657 .on_action(cx.listener(Self::confirm))
4658 .on_action(cx.listener(Self::cancel))
4659 .on_action(cx.listener(Self::copy_path))
4660 .on_action(cx.listener(Self::copy_relative_path))
4661 .on_action(cx.listener(Self::new_search_in_directory))
4662 .on_action(cx.listener(Self::unfold_directory))
4663 .on_action(cx.listener(Self::fold_directory))
4664 .on_action(cx.listener(Self::remove_from_project))
4665 .when(!project.is_read_only(cx), |el| {
4666 el.on_action(cx.listener(Self::new_file))
4667 .on_action(cx.listener(Self::new_directory))
4668 .on_action(cx.listener(Self::rename))
4669 .on_action(cx.listener(Self::delete))
4670 .on_action(cx.listener(Self::trash))
4671 .on_action(cx.listener(Self::cut))
4672 .on_action(cx.listener(Self::copy))
4673 .on_action(cx.listener(Self::paste))
4674 .on_action(cx.listener(Self::duplicate))
4675 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4676 if event.up.click_count > 1 {
4677 if let Some(entry_id) = this.last_worktree_root_id {
4678 let project = this.project.read(cx);
4679
4680 let worktree_id = if let Some(worktree) =
4681 project.worktree_for_entry(entry_id, cx)
4682 {
4683 worktree.read(cx).id()
4684 } else {
4685 return;
4686 };
4687
4688 this.selection = Some(SelectedEntry {
4689 worktree_id,
4690 entry_id,
4691 });
4692
4693 this.new_file(&NewFile, window, cx);
4694 }
4695 }
4696 }))
4697 })
4698 .when(project.is_local(), |el| {
4699 el.on_action(cx.listener(Self::reveal_in_finder))
4700 .on_action(cx.listener(Self::open_system))
4701 .on_action(cx.listener(Self::open_in_terminal))
4702 })
4703 .when(project.is_via_ssh(), |el| {
4704 el.on_action(cx.listener(Self::open_in_terminal))
4705 })
4706 .on_mouse_down(
4707 MouseButton::Right,
4708 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4709 // When deploying the context menu anywhere below the last project entry,
4710 // act as if the user clicked the root of the last worktree.
4711 if let Some(entry_id) = this.last_worktree_root_id {
4712 this.deploy_context_menu(event.position, entry_id, window, cx);
4713 }
4714 }),
4715 )
4716 .track_focus(&self.focus_handle(cx))
4717 .child(
4718 uniform_list(cx.entity().clone(), "entries", item_count, {
4719 |this, range, window, cx| {
4720 let mut items = Vec::with_capacity(range.end - range.start);
4721 this.for_each_visible_entry(
4722 range,
4723 window,
4724 cx,
4725 |id, details, window, cx| {
4726 items.push(this.render_entry(id, details, window, cx));
4727 },
4728 );
4729 items
4730 }
4731 })
4732 .when(show_indent_guides, |list| {
4733 list.with_decoration(
4734 ui::indent_guides(
4735 cx.entity().clone(),
4736 px(indent_size),
4737 IndentGuideColors::panel(cx),
4738 |this, range, window, cx| {
4739 let mut items =
4740 SmallVec::with_capacity(range.end - range.start);
4741 this.iter_visible_entries(
4742 range,
4743 window,
4744 cx,
4745 |entry, entries, _, _| {
4746 let (depth, _) = Self::calculate_depth_and_difference(
4747 entry, entries,
4748 );
4749 items.push(depth);
4750 },
4751 );
4752 items
4753 },
4754 )
4755 .on_click(cx.listener(
4756 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4757 if window.modifiers().secondary() {
4758 let ix = active_indent_guide.offset.y;
4759 let Some((target_entry, worktree)) = maybe!({
4760 let (worktree_id, entry) = this.entry_at_index(ix)?;
4761 let worktree = this
4762 .project
4763 .read(cx)
4764 .worktree_for_id(worktree_id, cx)?;
4765 let target_entry = worktree
4766 .read(cx)
4767 .entry_for_path(&entry.path.parent()?)?;
4768 Some((target_entry, worktree))
4769 }) else {
4770 return;
4771 };
4772
4773 this.collapse_entry(target_entry.clone(), worktree, cx);
4774 }
4775 },
4776 ))
4777 .with_render_fn(
4778 cx.entity().clone(),
4779 move |this, params, _, cx| {
4780 const LEFT_OFFSET: Pixels = px(14.);
4781 const PADDING_Y: Pixels = px(4.);
4782 const HITBOX_OVERDRAW: Pixels = px(3.);
4783
4784 let active_indent_guide_index =
4785 this.find_active_indent_guide(¶ms.indent_guides, cx);
4786
4787 let indent_size = params.indent_size;
4788 let item_height = params.item_height;
4789
4790 params
4791 .indent_guides
4792 .into_iter()
4793 .enumerate()
4794 .map(|(idx, layout)| {
4795 let offset = if layout.continues_offscreen {
4796 px(0.)
4797 } else {
4798 PADDING_Y
4799 };
4800 let bounds = Bounds::new(
4801 point(
4802 layout.offset.x * indent_size + LEFT_OFFSET,
4803 layout.offset.y * item_height + offset,
4804 ),
4805 size(
4806 px(1.),
4807 layout.length * item_height - offset * 2.,
4808 ),
4809 );
4810 ui::RenderedIndentGuide {
4811 bounds,
4812 layout,
4813 is_active: Some(idx) == active_indent_guide_index,
4814 hitbox: Some(Bounds::new(
4815 point(
4816 bounds.origin.x - HITBOX_OVERDRAW,
4817 bounds.origin.y,
4818 ),
4819 size(
4820 bounds.size.width + HITBOX_OVERDRAW * 2.,
4821 bounds.size.height,
4822 ),
4823 )),
4824 }
4825 })
4826 .collect()
4827 },
4828 ),
4829 )
4830 })
4831 .size_full()
4832 .with_sizing_behavior(ListSizingBehavior::Infer)
4833 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4834 .with_width_from_item(self.max_width_item_index)
4835 .track_scroll(self.scroll_handle.clone()),
4836 )
4837 .children(self.render_vertical_scrollbar(cx))
4838 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4839 this.pb_4().child(scrollbar)
4840 })
4841 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4842 deferred(
4843 anchored()
4844 .position(*position)
4845 .anchor(gpui::Corner::TopLeft)
4846 .child(menu.clone()),
4847 )
4848 .with_priority(1)
4849 }))
4850 } else {
4851 v_flex()
4852 .id("empty-project_panel")
4853 .size_full()
4854 .p_4()
4855 .track_focus(&self.focus_handle(cx))
4856 .child(
4857 Button::new("open_project", "Open a project")
4858 .full_width()
4859 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
4860 .on_click(cx.listener(|this, _, window, cx| {
4861 this.workspace
4862 .update(cx, |_, cx| {
4863 window.dispatch_action(Box::new(workspace::Open), cx)
4864 })
4865 .log_err();
4866 })),
4867 )
4868 .when(is_local, |div| {
4869 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4870 style.bg(cx.theme().colors().drop_target_background)
4871 })
4872 .on_drop(cx.listener(
4873 move |this, external_paths: &ExternalPaths, window, cx| {
4874 this.last_external_paths_drag_over_entry = None;
4875 this.marked_entries.clear();
4876 this.hover_scroll_task.take();
4877 if let Some(task) = this
4878 .workspace
4879 .update(cx, |workspace, cx| {
4880 workspace.open_workspace_for_paths(
4881 true,
4882 external_paths.paths().to_owned(),
4883 window,
4884 cx,
4885 )
4886 })
4887 .log_err()
4888 {
4889 task.detach_and_log_err(cx);
4890 }
4891 cx.stop_propagation();
4892 },
4893 ))
4894 })
4895 }
4896 }
4897}
4898
4899impl Render for DraggedProjectEntryView {
4900 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4901 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4902 h_flex()
4903 .font(ui_font)
4904 .pl(self.click_offset.x + px(12.))
4905 .pt(self.click_offset.y + px(12.))
4906 .child(
4907 div()
4908 .flex()
4909 .gap_1()
4910 .items_center()
4911 .py_1()
4912 .px_2()
4913 .rounded_lg()
4914 .bg(cx.theme().colors().background)
4915 .map(|this| {
4916 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4917 this.child(Label::new(format!("{} entries", self.selections.len())))
4918 } else {
4919 this.child(if let Some(icon) = &self.details.icon {
4920 div().child(Icon::from_path(icon.clone()))
4921 } else {
4922 div()
4923 })
4924 .child(Label::new(self.details.filename.clone()))
4925 }
4926 }),
4927 )
4928 }
4929}
4930
4931impl EventEmitter<Event> for ProjectPanel {}
4932
4933impl EventEmitter<PanelEvent> for ProjectPanel {}
4934
4935impl Panel for ProjectPanel {
4936 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4937 match ProjectPanelSettings::get_global(cx).dock {
4938 ProjectPanelDockPosition::Left => DockPosition::Left,
4939 ProjectPanelDockPosition::Right => DockPosition::Right,
4940 }
4941 }
4942
4943 fn position_is_valid(&self, position: DockPosition) -> bool {
4944 matches!(position, DockPosition::Left | DockPosition::Right)
4945 }
4946
4947 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4948 settings::update_settings_file::<ProjectPanelSettings>(
4949 self.fs.clone(),
4950 cx,
4951 move |settings, _| {
4952 let dock = match position {
4953 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4954 DockPosition::Right => ProjectPanelDockPosition::Right,
4955 };
4956 settings.dock = Some(dock);
4957 },
4958 );
4959 }
4960
4961 fn size(&self, _: &Window, cx: &App) -> Pixels {
4962 self.width
4963 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4964 }
4965
4966 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4967 self.width = size;
4968 self.serialize(cx);
4969 cx.notify();
4970 }
4971
4972 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4973 ProjectPanelSettings::get_global(cx)
4974 .button
4975 .then_some(IconName::FileTree)
4976 }
4977
4978 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4979 Some("Project Panel")
4980 }
4981
4982 fn toggle_action(&self) -> Box<dyn Action> {
4983 Box::new(ToggleFocus)
4984 }
4985
4986 fn persistent_name() -> &'static str {
4987 "Project Panel"
4988 }
4989
4990 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4991 let project = &self.project.read(cx);
4992 project.visible_worktrees(cx).any(|tree| {
4993 tree.read(cx)
4994 .root_entry()
4995 .map_or(false, |entry| entry.is_dir())
4996 })
4997 }
4998
4999 fn activation_priority(&self) -> u32 {
5000 0
5001 }
5002}
5003
5004impl Focusable for ProjectPanel {
5005 fn focus_handle(&self, _cx: &App) -> FocusHandle {
5006 self.focus_handle.clone()
5007 }
5008}
5009
5010impl ClipboardEntry {
5011 fn is_cut(&self) -> bool {
5012 matches!(self, Self::Cut { .. })
5013 }
5014
5015 fn items(&self) -> &BTreeSet<SelectedEntry> {
5016 match self {
5017 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
5018 }
5019 }
5020}
5021
5022#[cfg(test)]
5023mod project_panel_tests;