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