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