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