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