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 if let Some((worktree_id, entry_id)) = new_selected_entry {
3430 new_state.selection = Some(SelectedEntry {
3431 worktree_id,
3432 entry_id,
3433 });
3434 }
3435 new_state
3436 })
3437 .await;
3438 this.update_in(cx, |this, window, cx| {
3439 this.state = new_state;
3440 let elapsed = now.elapsed();
3441 if this.last_reported_update.elapsed() > Duration::from_secs(3600) {
3442 telemetry::event!(
3443 "Project Panel Updated",
3444 elapsed_ms = elapsed.as_millis() as u64,
3445 worktree_entries = this
3446 .state
3447 .visible_entries
3448 .iter()
3449 .map(|worktree| worktree.entries.len())
3450 .sum::<usize>(),
3451 )
3452 }
3453 if focus_filename_editor {
3454 this.filename_editor.update(cx, |editor, cx| {
3455 editor.clear(window, cx);
3456 window.focus(&editor.focus_handle(cx));
3457 });
3458 }
3459 if autoscroll {
3460 this.autoscroll(cx);
3461 }
3462 cx.notify();
3463 })
3464 .ok();
3465 });
3466 }
3467
3468 fn expand_entry(
3469 &mut self,
3470 worktree_id: WorktreeId,
3471 entry_id: ProjectEntryId,
3472 cx: &mut Context<Self>,
3473 ) {
3474 self.project.update(cx, |project, cx| {
3475 if let Some((worktree, expanded_dir_ids)) = project
3476 .worktree_for_id(worktree_id, cx)
3477 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
3478 {
3479 project.expand_entry(worktree_id, entry_id, cx);
3480 let worktree = worktree.read(cx);
3481
3482 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
3483 loop {
3484 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
3485 expanded_dir_ids.insert(ix, entry.id);
3486 }
3487
3488 if let Some(parent_entry) =
3489 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
3490 {
3491 entry = parent_entry;
3492 } else {
3493 break;
3494 }
3495 }
3496 }
3497 }
3498 });
3499 }
3500
3501 fn drop_external_files(
3502 &mut self,
3503 paths: &[PathBuf],
3504 entry_id: ProjectEntryId,
3505 window: &mut Window,
3506 cx: &mut Context<Self>,
3507 ) {
3508 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
3509
3510 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
3511
3512 let Some((target_directory, worktree, fs)) = maybe!({
3513 let project = self.project.read(cx);
3514 let fs = project.fs().clone();
3515 let worktree = project.worktree_for_entry(entry_id, cx)?;
3516 let entry = worktree.read(cx).entry_for_id(entry_id)?;
3517 let path = entry.path.clone();
3518 let target_directory = if entry.is_dir() {
3519 path
3520 } else {
3521 path.parent()?.into()
3522 };
3523 Some((target_directory, worktree, fs))
3524 }) else {
3525 return;
3526 };
3527
3528 let mut paths_to_replace = Vec::new();
3529 for path in &paths {
3530 if let Some(name) = path.file_name()
3531 && let Some(name) = name.to_str()
3532 {
3533 let target_path = target_directory.join(RelPath::unix(name).unwrap());
3534 if worktree.read(cx).entry_for_path(&target_path).is_some() {
3535 paths_to_replace.push((name.to_string(), path.clone()));
3536 }
3537 }
3538 }
3539
3540 cx.spawn_in(window, async move |this, cx| {
3541 async move {
3542 for (filename, original_path) in &paths_to_replace {
3543 let answer = cx.update(|window, cx| {
3544 window
3545 .prompt(
3546 PromptLevel::Info,
3547 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
3548 None,
3549 &["Replace", "Cancel"],
3550 cx,
3551 )
3552 })?.await?;
3553
3554 if answer == 1
3555 && let Some(item_idx) = paths.iter().position(|p| p == original_path) {
3556 paths.remove(item_idx);
3557 }
3558 }
3559
3560 if paths.is_empty() {
3561 return Ok(());
3562 }
3563
3564 let task = worktree.update( cx, |worktree, cx| {
3565 worktree.copy_external_entries(target_directory, paths, fs, cx)
3566 })?;
3567
3568 let opened_entries = task.await.with_context(|| "failed to copy external paths")?;
3569 this.update(cx, |this, cx| {
3570 if open_file_after_drop && !opened_entries.is_empty() {
3571 this.open_entry(opened_entries[0], true, false, cx);
3572 }
3573 })
3574 }
3575 .log_err().await
3576 })
3577 .detach();
3578 }
3579
3580 fn refresh_drag_cursor_style(
3581 &self,
3582 modifiers: &Modifiers,
3583 window: &mut Window,
3584 cx: &mut Context<Self>,
3585 ) {
3586 if let Some(existing_cursor) = cx.active_drag_cursor_style() {
3587 let new_cursor = if Self::is_copy_modifier_set(modifiers) {
3588 CursorStyle::DragCopy
3589 } else {
3590 CursorStyle::PointingHand
3591 };
3592 if existing_cursor != new_cursor {
3593 cx.set_active_drag_cursor_style(new_cursor, window);
3594 }
3595 }
3596 }
3597
3598 fn is_copy_modifier_set(modifiers: &Modifiers) -> bool {
3599 cfg!(target_os = "macos") && modifiers.alt
3600 || cfg!(not(target_os = "macos")) && modifiers.control
3601 }
3602
3603 fn drag_onto(
3604 &mut self,
3605 selections: &DraggedSelection,
3606 target_entry_id: ProjectEntryId,
3607 is_file: bool,
3608 window: &mut Window,
3609 cx: &mut Context<Self>,
3610 ) {
3611 if Self::is_copy_modifier_set(&window.modifiers()) {
3612 let _ = maybe!({
3613 let project = self.project.read(cx);
3614 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
3615 let worktree_id = target_worktree.read(cx).id();
3616 let target_entry = target_worktree
3617 .read(cx)
3618 .entry_for_id(target_entry_id)?
3619 .clone();
3620
3621 let mut copy_tasks = Vec::new();
3622 let mut disambiguation_range = None;
3623 for selection in selections.items() {
3624 let (new_path, new_disambiguation_range) = self.create_paste_path(
3625 selection,
3626 (target_worktree.clone(), &target_entry),
3627 cx,
3628 )?;
3629
3630 let task = self.project.update(cx, |project, cx| {
3631 project.copy_entry(selection.entry_id, (worktree_id, new_path).into(), cx)
3632 });
3633 copy_tasks.push(task);
3634 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3635 }
3636
3637 let item_count = copy_tasks.len();
3638
3639 cx.spawn_in(window, async move |project_panel, cx| {
3640 let mut last_succeed = None;
3641 for task in copy_tasks.into_iter() {
3642 if let Some(Some(entry)) = task.await.log_err() {
3643 last_succeed = Some(entry.id);
3644 }
3645 }
3646 // update selection
3647 if let Some(entry_id) = last_succeed {
3648 project_panel
3649 .update_in(cx, |project_panel, window, cx| {
3650 project_panel.state.selection = Some(SelectedEntry {
3651 worktree_id,
3652 entry_id,
3653 });
3654
3655 // if only one entry was dragged and it was disambiguated, open the rename editor
3656 if item_count == 1 && disambiguation_range.is_some() {
3657 project_panel.rename_impl(disambiguation_range, window, cx);
3658 }
3659 })
3660 .ok();
3661 }
3662 })
3663 .detach();
3664 Some(())
3665 });
3666 } else {
3667 for selection in selections.items() {
3668 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
3669 }
3670 }
3671 }
3672
3673 fn index_for_entry(
3674 &self,
3675 entry_id: ProjectEntryId,
3676 worktree_id: WorktreeId,
3677 ) -> Option<(usize, usize, usize)> {
3678 let mut total_ix = 0;
3679 for (worktree_ix, visible) in self.state.visible_entries.iter().enumerate() {
3680 if worktree_id != visible.worktree_id {
3681 total_ix += visible.entries.len();
3682 continue;
3683 }
3684
3685 return visible
3686 .entries
3687 .iter()
3688 .enumerate()
3689 .find(|(_, entry)| entry.id == entry_id)
3690 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
3691 }
3692 None
3693 }
3694
3695 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef<'_>)> {
3696 let mut offset = 0;
3697 for worktree in &self.state.visible_entries {
3698 let current_len = worktree.entries.len();
3699 if index < offset + current_len {
3700 return worktree
3701 .entries
3702 .get(index - offset)
3703 .map(|entry| (worktree.worktree_id, entry.to_ref()));
3704 }
3705 offset += current_len;
3706 }
3707 None
3708 }
3709
3710 fn iter_visible_entries(
3711 &self,
3712 range: Range<usize>,
3713 window: &mut Window,
3714 cx: &mut Context<ProjectPanel>,
3715 mut callback: impl FnMut(
3716 &Entry,
3717 usize,
3718 &HashSet<Arc<RelPath>>,
3719 &mut Window,
3720 &mut Context<ProjectPanel>,
3721 ),
3722 ) {
3723 let mut ix = 0;
3724 for visible in &self.state.visible_entries {
3725 if ix >= range.end {
3726 return;
3727 }
3728
3729 if ix + visible.entries.len() <= range.start {
3730 ix += visible.entries.len();
3731 continue;
3732 }
3733
3734 let end_ix = range.end.min(ix + visible.entries.len());
3735 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3736 let entries = visible
3737 .index
3738 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
3739 let base_index = ix + entry_range.start;
3740 for (i, entry) in visible.entries[entry_range].iter().enumerate() {
3741 let global_index = base_index + i;
3742 callback(entry, global_index, entries, window, cx);
3743 }
3744 ix = end_ix;
3745 }
3746 }
3747
3748 fn for_each_visible_entry(
3749 &self,
3750 range: Range<usize>,
3751 window: &mut Window,
3752 cx: &mut Context<ProjectPanel>,
3753 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3754 ) {
3755 let mut ix = 0;
3756 for visible in &self.state.visible_entries {
3757 if ix >= range.end {
3758 return;
3759 }
3760
3761 if ix + visible.entries.len() <= range.start {
3762 ix += visible.entries.len();
3763 continue;
3764 }
3765
3766 let end_ix = range.end.min(ix + visible.entries.len());
3767 let git_status_setting = {
3768 let settings = ProjectPanelSettings::get_global(cx);
3769 settings.git_status
3770 };
3771 if let Some(worktree) = self
3772 .project
3773 .read(cx)
3774 .worktree_for_id(visible.worktree_id, cx)
3775 {
3776 let snapshot = worktree.read(cx).snapshot();
3777 let root_name = snapshot.root_name();
3778
3779 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3780 let entries = visible
3781 .index
3782 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
3783 for entry in visible.entries[entry_range].iter() {
3784 let status = git_status_setting
3785 .then_some(entry.git_summary)
3786 .unwrap_or_default();
3787
3788 let mut details = self.details_for_entry(
3789 entry,
3790 visible.worktree_id,
3791 root_name,
3792 entries,
3793 status,
3794 None,
3795 window,
3796 cx,
3797 );
3798
3799 if let Some(edit_state) = &self.state.edit_state {
3800 let is_edited_entry = if edit_state.is_new_entry() {
3801 entry.id == NEW_ENTRY_ID
3802 } else {
3803 entry.id == edit_state.entry_id
3804 || self.state.ancestors.get(&entry.id).is_some_and(
3805 |auto_folded_dirs| {
3806 auto_folded_dirs.ancestors.contains(&edit_state.entry_id)
3807 },
3808 )
3809 };
3810
3811 if is_edited_entry {
3812 if let Some(processing_filename) = &edit_state.processing_filename {
3813 details.is_processing = true;
3814 if let Some(ancestors) = edit_state
3815 .leaf_entry_id
3816 .and_then(|entry| self.state.ancestors.get(&entry))
3817 {
3818 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;
3819 let all_components = ancestors.ancestors.len();
3820
3821 let prefix_components = all_components - position;
3822 let suffix_components = position.checked_sub(1);
3823 let mut previous_components =
3824 Path::new(&details.filename).components();
3825 let mut new_path = previous_components
3826 .by_ref()
3827 .take(prefix_components)
3828 .collect::<PathBuf>();
3829 if let Some(last_component) =
3830 processing_filename.components().next_back()
3831 {
3832 new_path.push(last_component);
3833 previous_components.next();
3834 }
3835
3836 if suffix_components.is_some() {
3837 new_path.push(previous_components);
3838 }
3839 if let Some(str) = new_path.to_str() {
3840 details.filename.clear();
3841 details.filename.push_str(str);
3842 }
3843 } else {
3844 details.filename.clear();
3845 details.filename.push_str(processing_filename.as_unix_str());
3846 }
3847 } else {
3848 if edit_state.is_new_entry() {
3849 details.filename.clear();
3850 }
3851 details.is_editing = true;
3852 }
3853 }
3854 }
3855
3856 callback(entry.id, details, window, cx);
3857 }
3858 }
3859 ix = end_ix;
3860 }
3861 }
3862
3863 fn find_entry_in_worktree(
3864 &self,
3865 worktree_id: WorktreeId,
3866 reverse_search: bool,
3867 only_visible_entries: bool,
3868 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3869 cx: &mut Context<Self>,
3870 ) -> Option<GitEntry> {
3871 if only_visible_entries {
3872 let entries = self
3873 .state
3874 .visible_entries
3875 .iter()
3876 .find_map(|visible| {
3877 if worktree_id == visible.worktree_id {
3878 Some(&visible.entries)
3879 } else {
3880 None
3881 }
3882 })?
3883 .clone();
3884
3885 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3886 .find(|ele| predicate(ele.to_ref(), worktree_id))
3887 .cloned();
3888 }
3889
3890 let repo_snapshots = self
3891 .project
3892 .read(cx)
3893 .git_store()
3894 .read(cx)
3895 .repo_snapshots(cx);
3896 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3897 worktree.read_with(cx, |tree, _| {
3898 utils::ReversibleIterable::new(
3899 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
3900 reverse_search,
3901 )
3902 .find_single_ended(|ele| predicate(*ele, worktree_id))
3903 .map(|ele| ele.to_owned())
3904 })
3905 }
3906
3907 fn find_entry(
3908 &self,
3909 start: Option<&SelectedEntry>,
3910 reverse_search: bool,
3911 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3912 cx: &mut Context<Self>,
3913 ) -> Option<SelectedEntry> {
3914 let mut worktree_ids: Vec<_> = self
3915 .state
3916 .visible_entries
3917 .iter()
3918 .map(|worktree| worktree.worktree_id)
3919 .collect();
3920 let repo_snapshots = self
3921 .project
3922 .read(cx)
3923 .git_store()
3924 .read(cx)
3925 .repo_snapshots(cx);
3926
3927 let mut last_found: Option<SelectedEntry> = None;
3928
3929 if let Some(start) = start {
3930 let worktree = self
3931 .project
3932 .read(cx)
3933 .worktree_for_id(start.worktree_id, cx)?
3934 .read(cx);
3935
3936 let search = {
3937 let entry = worktree.entry_for_id(start.entry_id)?;
3938 let root_entry = worktree.root_entry()?;
3939 let tree_id = worktree.id();
3940
3941 let mut first_iter = GitTraversal::new(
3942 &repo_snapshots,
3943 worktree.traverse_from_path(true, true, true, entry.path.as_ref()),
3944 );
3945
3946 if reverse_search {
3947 first_iter.next();
3948 }
3949
3950 let first = first_iter
3951 .enumerate()
3952 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3953 .map(|(_, entry)| entry)
3954 .find(|ele| predicate(*ele, tree_id))
3955 .map(|ele| ele.to_owned());
3956
3957 let second_iter =
3958 GitTraversal::new(&repo_snapshots, worktree.entries(true, 0usize));
3959
3960 let second = if reverse_search {
3961 second_iter
3962 .take_until(|ele| ele.id == start.entry_id)
3963 .filter(|ele| predicate(*ele, tree_id))
3964 .last()
3965 .map(|ele| ele.to_owned())
3966 } else {
3967 second_iter
3968 .take_while(|ele| ele.id != start.entry_id)
3969 .filter(|ele| predicate(*ele, tree_id))
3970 .last()
3971 .map(|ele| ele.to_owned())
3972 };
3973
3974 if reverse_search {
3975 Some((second, first))
3976 } else {
3977 Some((first, second))
3978 }
3979 };
3980
3981 if let Some((first, second)) = search {
3982 let first = first.map(|entry| SelectedEntry {
3983 worktree_id: start.worktree_id,
3984 entry_id: entry.id,
3985 });
3986
3987 let second = second.map(|entry| SelectedEntry {
3988 worktree_id: start.worktree_id,
3989 entry_id: entry.id,
3990 });
3991
3992 if first.is_some() {
3993 return first;
3994 }
3995 last_found = second;
3996
3997 let idx = worktree_ids
3998 .iter()
3999 .enumerate()
4000 .find(|(_, ele)| **ele == start.worktree_id)
4001 .map(|(idx, _)| idx);
4002
4003 if let Some(idx) = idx {
4004 worktree_ids.rotate_left(idx + 1usize);
4005 worktree_ids.pop();
4006 }
4007 }
4008 }
4009
4010 for tree_id in worktree_ids.into_iter() {
4011 if let Some(found) =
4012 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
4013 {
4014 return Some(SelectedEntry {
4015 worktree_id: tree_id,
4016 entry_id: found.id,
4017 });
4018 }
4019 }
4020
4021 last_found
4022 }
4023
4024 fn find_visible_entry(
4025 &self,
4026 start: Option<&SelectedEntry>,
4027 reverse_search: bool,
4028 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
4029 cx: &mut Context<Self>,
4030 ) -> Option<SelectedEntry> {
4031 let mut worktree_ids: Vec<_> = self
4032 .state
4033 .visible_entries
4034 .iter()
4035 .map(|worktree| worktree.worktree_id)
4036 .collect();
4037
4038 let mut last_found: Option<SelectedEntry> = None;
4039
4040 if let Some(start) = start {
4041 let entries = self
4042 .state
4043 .visible_entries
4044 .iter()
4045 .find(|worktree| worktree.worktree_id == start.worktree_id)
4046 .map(|worktree| &worktree.entries)?;
4047
4048 let mut start_idx = entries
4049 .iter()
4050 .enumerate()
4051 .find(|(_, ele)| ele.id == start.entry_id)
4052 .map(|(idx, _)| idx)?;
4053
4054 if reverse_search {
4055 start_idx = start_idx.saturating_add(1usize);
4056 }
4057
4058 let (left, right) = entries.split_at_checked(start_idx)?;
4059
4060 let (first_iter, second_iter) = if reverse_search {
4061 (
4062 utils::ReversibleIterable::new(left.iter(), reverse_search),
4063 utils::ReversibleIterable::new(right.iter(), reverse_search),
4064 )
4065 } else {
4066 (
4067 utils::ReversibleIterable::new(right.iter(), reverse_search),
4068 utils::ReversibleIterable::new(left.iter(), reverse_search),
4069 )
4070 };
4071
4072 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
4073 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
4074
4075 if first_search.is_some() {
4076 return first_search.map(|entry| SelectedEntry {
4077 worktree_id: start.worktree_id,
4078 entry_id: entry.id,
4079 });
4080 }
4081
4082 last_found = second_search.map(|entry| SelectedEntry {
4083 worktree_id: start.worktree_id,
4084 entry_id: entry.id,
4085 });
4086
4087 let idx = worktree_ids
4088 .iter()
4089 .enumerate()
4090 .find(|(_, ele)| **ele == start.worktree_id)
4091 .map(|(idx, _)| idx);
4092
4093 if let Some(idx) = idx {
4094 worktree_ids.rotate_left(idx + 1usize);
4095 worktree_ids.pop();
4096 }
4097 }
4098
4099 for tree_id in worktree_ids.into_iter() {
4100 if let Some(found) =
4101 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
4102 {
4103 return Some(SelectedEntry {
4104 worktree_id: tree_id,
4105 entry_id: found.id,
4106 });
4107 }
4108 }
4109
4110 last_found
4111 }
4112
4113 fn calculate_depth_and_difference(
4114 entry: &Entry,
4115 visible_worktree_entries: &HashSet<Arc<RelPath>>,
4116 ) -> (usize, usize) {
4117 let (depth, difference) = entry
4118 .path
4119 .ancestors()
4120 .skip(1) // Skip the entry itself
4121 .find_map(|ancestor| {
4122 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
4123 let entry_path_components_count = entry.path.components().count();
4124 let parent_path_components_count = parent_entry.components().count();
4125 let difference = entry_path_components_count - parent_path_components_count;
4126 let depth = parent_entry
4127 .ancestors()
4128 .skip(1)
4129 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
4130 .count();
4131 Some((depth + 1, difference))
4132 } else {
4133 None
4134 }
4135 })
4136 .unwrap_or_else(|| (0, entry.path.components().count()));
4137
4138 (depth, difference)
4139 }
4140
4141 fn highlight_entry_for_external_drag(
4142 &self,
4143 target_entry: &Entry,
4144 target_worktree: &Worktree,
4145 ) -> Option<ProjectEntryId> {
4146 // Always highlight directory or parent directory if it's file
4147 if target_entry.is_dir() {
4148 Some(target_entry.id)
4149 } else {
4150 target_entry
4151 .path
4152 .parent()
4153 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
4154 .map(|parent_entry| parent_entry.id)
4155 }
4156 }
4157
4158 fn highlight_entry_for_selection_drag(
4159 &self,
4160 target_entry: &Entry,
4161 target_worktree: &Worktree,
4162 drag_state: &DraggedSelection,
4163 cx: &Context<Self>,
4164 ) -> Option<ProjectEntryId> {
4165 let target_parent_path = target_entry.path.parent();
4166
4167 // In case of single item drag, we do not highlight existing
4168 // directory which item belongs too
4169 if drag_state.items().count() == 1
4170 && drag_state.active_selection.worktree_id == target_worktree.id()
4171 {
4172 let active_entry_path = self
4173 .project
4174 .read(cx)
4175 .path_for_entry(drag_state.active_selection.entry_id, cx)?;
4176
4177 if let Some(active_parent_path) = active_entry_path.path.parent() {
4178 // Do not highlight active entry parent
4179 if active_parent_path == target_entry.path.as_ref() {
4180 return None;
4181 }
4182
4183 // Do not highlight active entry sibling files
4184 if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
4185 return None;
4186 }
4187 }
4188 }
4189
4190 // Always highlight directory or parent directory if it's file
4191 if target_entry.is_dir() {
4192 Some(target_entry.id)
4193 } else {
4194 target_parent_path
4195 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
4196 .map(|parent_entry| parent_entry.id)
4197 }
4198 }
4199
4200 fn should_highlight_background_for_selection_drag(
4201 &self,
4202 drag_state: &DraggedSelection,
4203 last_root_id: ProjectEntryId,
4204 cx: &App,
4205 ) -> bool {
4206 // Always highlight for multiple entries
4207 if drag_state.items().count() > 1 {
4208 return true;
4209 }
4210
4211 // Since root will always have empty relative path
4212 if let Some(entry_path) = self
4213 .project
4214 .read(cx)
4215 .path_for_entry(drag_state.active_selection.entry_id, cx)
4216 {
4217 if let Some(parent_path) = entry_path.path.parent() {
4218 if !parent_path.is_empty() {
4219 return true;
4220 }
4221 }
4222 }
4223
4224 // If parent is empty, check if different worktree
4225 if let Some(last_root_worktree_id) = self
4226 .project
4227 .read(cx)
4228 .worktree_id_for_entry(last_root_id, cx)
4229 {
4230 if drag_state.active_selection.worktree_id != last_root_worktree_id {
4231 return true;
4232 }
4233 }
4234
4235 false
4236 }
4237
4238 fn render_entry(
4239 &self,
4240 entry_id: ProjectEntryId,
4241 details: EntryDetails,
4242 window: &mut Window,
4243 cx: &mut Context<Self>,
4244 ) -> Stateful<Div> {
4245 const GROUP_NAME: &str = "project_entry";
4246
4247 let kind = details.kind;
4248 let is_sticky = details.sticky.is_some();
4249 let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index);
4250 let settings = ProjectPanelSettings::get_global(cx);
4251 let show_editor = details.is_editing && !details.is_processing;
4252
4253 let selection = SelectedEntry {
4254 worktree_id: details.worktree_id,
4255 entry_id,
4256 };
4257
4258 let is_marked = self.marked_entries.contains(&selection);
4259 let is_active = self
4260 .state
4261 .selection
4262 .is_some_and(|selection| selection.entry_id == entry_id);
4263
4264 let file_name = details.filename.clone();
4265
4266 let mut icon = details.icon.clone();
4267 if settings.file_icons && show_editor && details.kind.is_file() {
4268 let filename = self.filename_editor.read(cx).text(cx);
4269 if filename.len() > 2 {
4270 icon = FileIcons::get_icon(Path::new(&filename), cx);
4271 }
4272 }
4273
4274 let filename_text_color = details.filename_text_color;
4275 let diagnostic_severity = details.diagnostic_severity;
4276 let item_colors = get_item_color(is_sticky, cx);
4277
4278 let canonical_path = details
4279 .canonical_path
4280 .as_ref()
4281 .map(|f| f.to_string_lossy().into_owned());
4282 let path_style = self.project.read(cx).path_style(cx);
4283 let path = details.path.clone();
4284 let path_for_external_paths = path.clone();
4285 let path_for_dragged_selection = path.clone();
4286
4287 let depth = details.depth;
4288 let worktree_id = details.worktree_id;
4289 let dragged_selection = DraggedSelection {
4290 active_selection: SelectedEntry {
4291 worktree_id: selection.worktree_id,
4292 entry_id: self.resolve_entry(selection.entry_id),
4293 },
4294 marked_selections: Arc::from(self.marked_entries.clone()),
4295 };
4296
4297 let bg_color = if is_marked {
4298 item_colors.marked
4299 } else {
4300 item_colors.default
4301 };
4302
4303 let bg_hover_color = if is_marked {
4304 item_colors.marked
4305 } else {
4306 item_colors.hover
4307 };
4308
4309 let validation_color_and_message = if show_editor {
4310 match self
4311 .state
4312 .edit_state
4313 .as_ref()
4314 .map_or(ValidationState::None, |e| e.validation_state.clone())
4315 {
4316 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg)),
4317 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg)),
4318 ValidationState::None => None,
4319 }
4320 } else {
4321 None
4322 };
4323
4324 let border_color =
4325 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
4326 match validation_color_and_message {
4327 Some((color, _)) => color,
4328 None => item_colors.focused,
4329 }
4330 } else {
4331 bg_color
4332 };
4333
4334 let border_hover_color =
4335 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
4336 match validation_color_and_message {
4337 Some((color, _)) => color,
4338 None => item_colors.focused,
4339 }
4340 } else {
4341 bg_hover_color
4342 };
4343
4344 let folded_directory_drag_target = self.folded_directory_drag_target;
4345 let is_highlighted = {
4346 if let Some(highlight_entry_id) =
4347 self.drag_target_entry
4348 .as_ref()
4349 .and_then(|drag_target| match drag_target {
4350 DragTarget::Entry {
4351 highlight_entry_id, ..
4352 } => Some(*highlight_entry_id),
4353 DragTarget::Background => self.state.last_worktree_root_id,
4354 })
4355 {
4356 // Highlight if same entry or it's children
4357 if entry_id == highlight_entry_id {
4358 true
4359 } else {
4360 maybe!({
4361 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
4362 let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
4363 Some(path.starts_with(&highlight_entry.path))
4364 })
4365 .unwrap_or(false)
4366 }
4367 } else {
4368 false
4369 }
4370 };
4371
4372 let id: ElementId = if is_sticky {
4373 SharedString::from(format!("project_panel_sticky_item_{}", entry_id.to_usize())).into()
4374 } else {
4375 (entry_id.to_proto() as usize).into()
4376 };
4377
4378 div()
4379 .id(id.clone())
4380 .relative()
4381 .group(GROUP_NAME)
4382 .cursor_pointer()
4383 .rounded_none()
4384 .bg(bg_color)
4385 .border_1()
4386 .border_r_2()
4387 .border_color(border_color)
4388 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
4389 .when(is_sticky, |this| {
4390 this.block_mouse_except_scroll()
4391 })
4392 .when(!is_sticky, |this| {
4393 this
4394 .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
4395 .when(settings.drag_and_drop, |this| this
4396 .on_drag_move::<ExternalPaths>(cx.listener(
4397 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
4398 let is_current_target = this.drag_target_entry.as_ref()
4399 .and_then(|entry| match entry {
4400 DragTarget::Entry { entry_id: target_id, .. } => Some(*target_id),
4401 DragTarget::Background { .. } => None,
4402 }) == Some(entry_id);
4403
4404 if !event.bounds.contains(&event.event.position) {
4405 // Entry responsible for setting drag target is also responsible to
4406 // clear it up after drag is out of bounds
4407 if is_current_target {
4408 this.drag_target_entry = None;
4409 }
4410 return;
4411 }
4412
4413 if is_current_target {
4414 return;
4415 }
4416
4417 this.marked_entries.clear();
4418
4419 let Some((entry_id, highlight_entry_id)) = maybe!({
4420 let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
4421 let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?;
4422 let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree)?;
4423 Some((target_entry.id, highlight_entry_id))
4424 }) else {
4425 return;
4426 };
4427
4428 this.drag_target_entry = Some(DragTarget::Entry {
4429 entry_id,
4430 highlight_entry_id,
4431 });
4432
4433 },
4434 ))
4435 .on_drop(cx.listener(
4436 move |this, external_paths: &ExternalPaths, window, cx| {
4437 this.drag_target_entry = None;
4438 this.hover_scroll_task.take();
4439 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
4440 cx.stop_propagation();
4441 },
4442 ))
4443 .on_drag_move::<DraggedSelection>(cx.listener(
4444 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
4445 let is_current_target = this.drag_target_entry.as_ref()
4446 .and_then(|entry| match entry {
4447 DragTarget::Entry { entry_id: target_id, .. } => Some(*target_id),
4448 DragTarget::Background { .. } => None,
4449 }) == Some(entry_id);
4450
4451 if !event.bounds.contains(&event.event.position) {
4452 // Entry responsible for setting drag target is also responsible to
4453 // clear it up after drag is out of bounds
4454 if is_current_target {
4455 this.drag_target_entry = None;
4456 }
4457 return;
4458 }
4459
4460 if is_current_target {
4461 return;
4462 }
4463
4464 let drag_state = event.drag(cx);
4465
4466 if drag_state.items().count() == 1 {
4467 this.marked_entries.clear();
4468 this.marked_entries.push(drag_state.active_selection);
4469 }
4470
4471 let Some((entry_id, highlight_entry_id)) = maybe!({
4472 let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
4473 let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
4474 let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx)?;
4475 Some((target_entry.id, highlight_entry_id))
4476 }) else {
4477 return;
4478 };
4479
4480 this.drag_target_entry = Some(DragTarget::Entry {
4481 entry_id,
4482 highlight_entry_id,
4483 });
4484
4485 this.hover_expand_task.take();
4486
4487 if !kind.is_dir()
4488 || this
4489 .state
4490 .expanded_dir_ids
4491 .get(&details.worktree_id)
4492 .is_some_and(|ids| ids.binary_search(&entry_id).is_ok())
4493 {
4494 return;
4495 }
4496
4497 let bounds = event.bounds;
4498 this.hover_expand_task =
4499 Some(cx.spawn_in(window, async move |this, cx| {
4500 cx.background_executor()
4501 .timer(Duration::from_millis(500))
4502 .await;
4503 this.update_in(cx, |this, window, cx| {
4504 this.hover_expand_task.take();
4505 if this.drag_target_entry.as_ref().and_then(|entry| match entry {
4506 DragTarget::Entry { entry_id: target_id, .. } => Some(*target_id),
4507 DragTarget::Background { .. } => None,
4508 }) == Some(entry_id)
4509 && bounds.contains(&window.mouse_position())
4510 {
4511 this.expand_entry(worktree_id, entry_id, cx);
4512 this.update_visible_entries(
4513 Some((worktree_id, entry_id)),
4514 false,
4515 false,
4516 window,
4517 cx,
4518 );
4519 cx.notify();
4520 }
4521 })
4522 .ok();
4523 }));
4524 },
4525 ))
4526 .on_drag(
4527 dragged_selection,
4528 {
4529 let active_component = self.state.ancestors.get(&entry_id).and_then(|ancestors| ancestors.active_component(&details.filename));
4530 move |selection, click_offset, _window, cx| {
4531 let filename = active_component.as_ref().unwrap_or_else(|| &details.filename);
4532 cx.new(|_| DraggedProjectEntryView {
4533 icon: details.icon.clone(),
4534 filename: filename.clone(),
4535 click_offset,
4536 selection: selection.active_selection,
4537 selections: selection.marked_selections.clone(),
4538 })
4539 }
4540 }
4541 )
4542 .on_drop(
4543 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
4544 this.drag_target_entry = None;
4545 this.hover_scroll_task.take();
4546 this.hover_expand_task.take();
4547 if folded_directory_drag_target.is_some() {
4548 return;
4549 }
4550 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
4551 }),
4552 ))
4553 })
4554 .on_mouse_down(
4555 MouseButton::Left,
4556 cx.listener(move |this, _, _, cx| {
4557 this.mouse_down = true;
4558 cx.propagate();
4559 }),
4560 )
4561 .on_click(
4562 cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
4563 if event.is_right_click() || event.first_focus()
4564 || show_editor
4565 {
4566 return;
4567 }
4568 if event.standard_click() {
4569 project_panel.mouse_down = false;
4570 }
4571 cx.stop_propagation();
4572
4573 if let Some(selection) = project_panel.state.selection.filter(|_| event.modifiers().shift) {
4574 let current_selection = project_panel.index_for_selection(selection);
4575 let clicked_entry = SelectedEntry {
4576 entry_id,
4577 worktree_id,
4578 };
4579 let target_selection = project_panel.index_for_selection(clicked_entry);
4580 if let Some(((_, _, source_index), (_, _, target_index))) =
4581 current_selection.zip(target_selection)
4582 {
4583 let range_start = source_index.min(target_index);
4584 let range_end = source_index.max(target_index) + 1;
4585 let mut new_selections = Vec::new();
4586 project_panel.for_each_visible_entry(
4587 range_start..range_end,
4588 window,
4589 cx,
4590 |entry_id, details, _, _| {
4591 new_selections.push(SelectedEntry {
4592 entry_id,
4593 worktree_id: details.worktree_id,
4594 });
4595 },
4596 );
4597
4598 for selection in &new_selections {
4599 if !project_panel.marked_entries.contains(selection) {
4600 project_panel.marked_entries.push(*selection);
4601 }
4602 }
4603
4604 project_panel.state.selection = Some(clicked_entry);
4605 if !project_panel.marked_entries.contains(&clicked_entry) {
4606 project_panel.marked_entries.push(clicked_entry);
4607 }
4608 }
4609 } else if event.modifiers().secondary() {
4610 if event.click_count() > 1 {
4611 project_panel.split_entry(entry_id, false, None, cx);
4612 } else {
4613 project_panel.state.selection = Some(selection);
4614 if let Some(position) = project_panel.marked_entries.iter().position(|e| *e == selection) {
4615 project_panel.marked_entries.remove(position);
4616 } else {
4617 project_panel.marked_entries.push(selection);
4618 }
4619 }
4620 } else if kind.is_dir() {
4621 project_panel.marked_entries.clear();
4622 if is_sticky
4623 && let Some((_, _, index)) = project_panel.index_for_entry(entry_id, worktree_id) {
4624 project_panel.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
4625 cx.notify();
4626 // move down by 1px so that clicked item
4627 // don't count as sticky anymore
4628 cx.on_next_frame(window, |_, window, cx| {
4629 cx.on_next_frame(window, |this, _, cx| {
4630 let mut offset = this.scroll_handle.offset();
4631 offset.y += px(1.);
4632 this.scroll_handle.set_offset(offset);
4633 cx.notify();
4634 });
4635 });
4636 return;
4637 }
4638 if event.modifiers().alt {
4639 project_panel.toggle_expand_all(entry_id, window, cx);
4640 } else {
4641 project_panel.toggle_expanded(entry_id, window, cx);
4642 }
4643 } else {
4644 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
4645 let click_count = event.click_count();
4646 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
4647 let allow_preview = preview_tabs_enabled && click_count == 1;
4648 project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
4649 }
4650 }),
4651 )
4652 .child(
4653 ListItem::new(id)
4654 .indent_level(depth)
4655 .indent_step_size(px(settings.indent_size))
4656 .spacing(match settings.entry_spacing {
4657 ProjectPanelEntrySpacing::Comfortable => ListItemSpacing::Dense,
4658 ProjectPanelEntrySpacing::Standard => {
4659 ListItemSpacing::ExtraDense
4660 }
4661 })
4662 .selectable(false)
4663 .when_some(canonical_path, |this, path| {
4664 this.end_slot::<AnyElement>(
4665 div()
4666 .id("symlink_icon")
4667 .pr_3()
4668 .tooltip(move |window, cx| {
4669 Tooltip::with_meta(
4670 path.to_string(),
4671 None,
4672 "Symbolic Link",
4673 window,
4674 cx,
4675 )
4676 })
4677 .child(
4678 Icon::new(IconName::ArrowUpRight)
4679 .size(IconSize::Indicator)
4680 .color(filename_text_color),
4681 )
4682 .into_any_element(),
4683 )
4684 })
4685 .child(if let Some(icon) = &icon {
4686 if let Some((_, decoration_color)) =
4687 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
4688 {
4689 let is_warning = diagnostic_severity
4690 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
4691 .unwrap_or(false);
4692 div().child(
4693 DecoratedIcon::new(
4694 Icon::from_path(icon.clone()).color(Color::Muted),
4695 Some(
4696 IconDecoration::new(
4697 if kind.is_file() {
4698 if is_warning {
4699 IconDecorationKind::Triangle
4700 } else {
4701 IconDecorationKind::X
4702 }
4703 } else {
4704 IconDecorationKind::Dot
4705 },
4706 bg_color,
4707 cx,
4708 )
4709 .group_name(Some(GROUP_NAME.into()))
4710 .knockout_hover_color(bg_hover_color)
4711 .color(decoration_color.color(cx))
4712 .position(Point {
4713 x: px(-2.),
4714 y: px(-2.),
4715 }),
4716 ),
4717 )
4718 .into_any_element(),
4719 )
4720 } else {
4721 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
4722 }
4723 } else if let Some((icon_name, color)) =
4724 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
4725 {
4726 h_flex()
4727 .size(IconSize::default().rems())
4728 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
4729 } else {
4730 h_flex()
4731 .size(IconSize::default().rems())
4732 .invisible()
4733 .flex_none()
4734 })
4735 .child(
4736 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
4737 h_flex().h_6().w_full().child(editor.clone())
4738 } else {
4739 h_flex().h_6().map(|mut this| {
4740 if let Some(folded_ancestors) = self.state.ancestors.get(&entry_id) {
4741 let components = Path::new(&file_name)
4742 .components()
4743 .map(|comp| comp.as_os_str().to_string_lossy().into_owned())
4744 .collect::<Vec<_>>();
4745 let active_index = folded_ancestors.active_index();
4746 let components_len = components.len();
4747 let delimiter = SharedString::new(path_style.separator());
4748 for (index, component) in components.iter().enumerate() {
4749 if index != 0 {
4750 let delimiter_target_index = index - 1;
4751 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
4752 this = this.child(
4753 div()
4754 .when(!is_sticky, |div| {
4755 div
4756 .when(settings.drag_and_drop, |div| div
4757 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
4758 this.hover_scroll_task.take();
4759 this.drag_target_entry = None;
4760 this.folded_directory_drag_target = None;
4761 if let Some(target_entry_id) = target_entry_id {
4762 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4763 }
4764 }))
4765 .on_drag_move(cx.listener(
4766 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4767 if event.bounds.contains(&event.event.position) {
4768 this.folded_directory_drag_target = Some(
4769 FoldedDirectoryDragTarget {
4770 entry_id,
4771 index: delimiter_target_index,
4772 is_delimiter_target: true,
4773 }
4774 );
4775 } else {
4776 let is_current_target = this.folded_directory_drag_target
4777 .is_some_and(|target|
4778 target.entry_id == entry_id &&
4779 target.index == delimiter_target_index &&
4780 target.is_delimiter_target
4781 );
4782 if is_current_target {
4783 this.folded_directory_drag_target = None;
4784 }
4785 }
4786
4787 },
4788 )))
4789 })
4790 .child(
4791 Label::new(delimiter.clone())
4792 .single_line()
4793 .color(filename_text_color)
4794 )
4795 );
4796 }
4797 let id = SharedString::from(format!(
4798 "project_panel_path_component_{}_{index}",
4799 entry_id.to_usize()
4800 ));
4801 let label = div()
4802 .id(id)
4803 .when(!is_sticky,| div| {
4804 div
4805 .when(index != components_len - 1, |div|{
4806 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
4807 div
4808 .when(settings.drag_and_drop, |div| div
4809 .on_drag_move(cx.listener(
4810 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4811 if event.bounds.contains(&event.event.position) {
4812 this.folded_directory_drag_target = Some(
4813 FoldedDirectoryDragTarget {
4814 entry_id,
4815 index,
4816 is_delimiter_target: false,
4817 }
4818 );
4819 } else {
4820 let is_current_target = this.folded_directory_drag_target
4821 .as_ref()
4822 .is_some_and(|target|
4823 target.entry_id == entry_id &&
4824 target.index == index &&
4825 !target.is_delimiter_target
4826 );
4827 if is_current_target {
4828 this.folded_directory_drag_target = None;
4829 }
4830 }
4831 },
4832 ))
4833 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
4834 this.hover_scroll_task.take();
4835 this.drag_target_entry = None;
4836 this.folded_directory_drag_target = None;
4837 if let Some(target_entry_id) = target_entry_id {
4838 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4839 }
4840 }))
4841 .when(folded_directory_drag_target.is_some_and(|target|
4842 target.entry_id == entry_id &&
4843 target.index == index
4844 ), |this| {
4845 this.bg(item_colors.drag_over)
4846 }))
4847 })
4848 })
4849 .on_mouse_down(
4850 MouseButton::Left,
4851 cx.listener(move |this, _, _, cx| {
4852 if index != active_index
4853 && let Some(folds) =
4854 this.state.ancestors.get_mut(&entry_id)
4855 {
4856 folds.current_ancestor_depth =
4857 components_len - 1 - index;
4858 cx.notify();
4859 }
4860 }),
4861 )
4862 .child(
4863 Label::new(component)
4864 .single_line()
4865 .color(filename_text_color)
4866 .when(
4867 index == active_index
4868 && (is_active || is_marked),
4869 |this| this.underline(),
4870 ),
4871 );
4872
4873 this = this.child(label);
4874 }
4875
4876 this
4877 } else {
4878 this.child(
4879 Label::new(file_name)
4880 .single_line()
4881 .color(filename_text_color),
4882 )
4883 }
4884 })
4885 },
4886 )
4887 .on_secondary_mouse_down(cx.listener(
4888 move |this, event: &MouseDownEvent, window, cx| {
4889 // Stop propagation to prevent the catch-all context menu for the project
4890 // panel from being deployed.
4891 cx.stop_propagation();
4892 // Some context menu actions apply to all marked entries. If the user
4893 // right-clicks on an entry that is not marked, they may not realize the
4894 // action applies to multiple entries. To avoid inadvertent changes, all
4895 // entries are unmarked.
4896 if !this.marked_entries.contains(&selection) {
4897 this.marked_entries.clear();
4898 }
4899 this.deploy_context_menu(event.position, entry_id, window, cx);
4900 },
4901 ))
4902 .overflow_x(),
4903 )
4904 .when_some(
4905 validation_color_and_message,
4906 |this, (color, message)| {
4907 this
4908 .relative()
4909 .child(
4910 deferred(
4911 div()
4912 .occlude()
4913 .absolute()
4914 .top_full()
4915 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
4916 .right(px(-0.5))
4917 .py_1()
4918 .px_2()
4919 .border_1()
4920 .border_color(color)
4921 .bg(cx.theme().colors().background)
4922 .child(
4923 Label::new(message)
4924 .color(Color::from(color))
4925 .size(LabelSize::Small)
4926 )
4927 )
4928 )
4929 }
4930 )
4931 }
4932
4933 fn details_for_entry(
4934 &self,
4935 entry: &Entry,
4936 worktree_id: WorktreeId,
4937 root_name: &RelPath,
4938 entries_paths: &HashSet<Arc<RelPath>>,
4939 git_status: GitSummary,
4940 sticky: Option<StickyDetails>,
4941 _window: &mut Window,
4942 cx: &mut Context<Self>,
4943 ) -> EntryDetails {
4944 let (show_file_icons, show_folder_icons) = {
4945 let settings = ProjectPanelSettings::get_global(cx);
4946 (settings.file_icons, settings.folder_icons)
4947 };
4948
4949 let expanded_entry_ids = self
4950 .state
4951 .expanded_dir_ids
4952 .get(&worktree_id)
4953 .map(Vec::as_slice)
4954 .unwrap_or(&[]);
4955 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
4956
4957 let icon = match entry.kind {
4958 EntryKind::File => {
4959 if show_file_icons {
4960 FileIcons::get_icon(entry.path.as_std_path(), cx)
4961 } else {
4962 None
4963 }
4964 }
4965 _ => {
4966 if show_folder_icons {
4967 FileIcons::get_folder_icon(is_expanded, entry.path.as_std_path(), cx)
4968 } else {
4969 FileIcons::get_chevron_icon(is_expanded, cx)
4970 }
4971 }
4972 };
4973
4974 let path_style = self.project.read(cx).path_style(cx);
4975 let (depth, difference) =
4976 ProjectPanel::calculate_depth_and_difference(entry, entries_paths);
4977
4978 let filename = if difference > 1 {
4979 entry
4980 .path
4981 .last_n_components(difference)
4982 .map_or(String::new(), |suffix| {
4983 suffix.display(path_style).to_string()
4984 })
4985 } else {
4986 entry
4987 .path
4988 .file_name()
4989 .map(|name| name.to_string())
4990 .unwrap_or_else(|| root_name.as_unix_str().to_string())
4991 };
4992
4993 let selection = SelectedEntry {
4994 worktree_id,
4995 entry_id: entry.id,
4996 };
4997 let is_marked = self.marked_entries.contains(&selection);
4998 let is_selected = self.state.selection == Some(selection);
4999
5000 let diagnostic_severity = self
5001 .diagnostics
5002 .get(&(worktree_id, entry.path.clone()))
5003 .cloned();
5004
5005 let filename_text_color =
5006 entry_git_aware_label_color(git_status, entry.is_ignored, is_marked);
5007
5008 let is_cut = self
5009 .clipboard
5010 .as_ref()
5011 .is_some_and(|e| e.is_cut() && e.items().contains(&selection));
5012
5013 EntryDetails {
5014 filename,
5015 icon,
5016 path: entry.path.clone(),
5017 depth,
5018 kind: entry.kind,
5019 is_ignored: entry.is_ignored,
5020 is_expanded,
5021 is_selected,
5022 is_marked,
5023 is_editing: false,
5024 is_processing: false,
5025 is_cut,
5026 sticky,
5027 filename_text_color,
5028 diagnostic_severity,
5029 git_status,
5030 is_private: entry.is_private,
5031 worktree_id,
5032 canonical_path: entry.canonical_path.clone(),
5033 }
5034 }
5035
5036 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
5037 let mut dispatch_context = KeyContext::new_with_defaults();
5038 dispatch_context.add("ProjectPanel");
5039 dispatch_context.add("menu");
5040
5041 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
5042 "editing"
5043 } else {
5044 "not_editing"
5045 };
5046
5047 dispatch_context.add(identifier);
5048 dispatch_context
5049 }
5050
5051 fn reveal_entry(
5052 &mut self,
5053 project: Entity<Project>,
5054 entry_id: ProjectEntryId,
5055 skip_ignored: bool,
5056 window: &mut Window,
5057 cx: &mut Context<Self>,
5058 ) -> Result<()> {
5059 let worktree = project
5060 .read(cx)
5061 .worktree_for_entry(entry_id, cx)
5062 .context("can't reveal a non-existent entry in the project panel")?;
5063 let worktree = worktree.read(cx);
5064 if skip_ignored
5065 && worktree
5066 .entry_for_id(entry_id)
5067 .is_none_or(|entry| entry.is_ignored && !entry.is_always_included)
5068 {
5069 anyhow::bail!("can't reveal an ignored entry in the project panel");
5070 }
5071 let is_active_item_file_diff_view = self
5072 .workspace
5073 .upgrade()
5074 .and_then(|ws| ws.read(cx).active_item(cx))
5075 .map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
5076 .unwrap_or(false);
5077 if is_active_item_file_diff_view {
5078 return Ok(());
5079 }
5080
5081 let worktree_id = worktree.id();
5082 self.expand_entry(worktree_id, entry_id, cx);
5083 self.update_visible_entries(Some((worktree_id, entry_id)), false, true, window, cx);
5084 self.marked_entries.clear();
5085 self.marked_entries.push(SelectedEntry {
5086 worktree_id,
5087 entry_id,
5088 });
5089 cx.notify();
5090 Ok(())
5091 }
5092
5093 fn find_active_indent_guide(
5094 &self,
5095 indent_guides: &[IndentGuideLayout],
5096 cx: &App,
5097 ) -> Option<usize> {
5098 let (worktree, entry) = self.selected_entry(cx)?;
5099
5100 // Find the parent entry of the indent guide, this will either be the
5101 // expanded folder we have selected, or the parent of the currently
5102 // selected file/collapsed directory
5103 let mut entry = entry;
5104 loop {
5105 let is_expanded_dir = entry.is_dir()
5106 && self
5107 .state
5108 .expanded_dir_ids
5109 .get(&worktree.id())
5110 .map(|ids| ids.binary_search(&entry.id).is_ok())
5111 .unwrap_or(false);
5112 if is_expanded_dir {
5113 break;
5114 }
5115 entry = worktree.entry_for_path(&entry.path.parent()?)?;
5116 }
5117
5118 let (active_indent_range, depth) = {
5119 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
5120 let child_paths = &self.state.visible_entries[worktree_ix].entries;
5121 let mut child_count = 0;
5122 let depth = entry.path.ancestors().count();
5123 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
5124 if entry.path.ancestors().count() <= depth {
5125 break;
5126 }
5127 child_count += 1;
5128 }
5129
5130 let start = ix + 1;
5131 let end = start + child_count;
5132
5133 let visible_worktree = &self.state.visible_entries[worktree_ix];
5134 let visible_worktree_entries = visible_worktree.index.get_or_init(|| {
5135 visible_worktree
5136 .entries
5137 .iter()
5138 .map(|e| e.path.clone())
5139 .collect()
5140 });
5141
5142 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
5143 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
5144 (start..end, depth)
5145 };
5146
5147 let candidates = indent_guides
5148 .iter()
5149 .enumerate()
5150 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
5151
5152 for (i, indent) in candidates {
5153 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
5154 if active_indent_range.start <= indent.offset.y + indent.length
5155 && indent.offset.y <= active_indent_range.end
5156 {
5157 return Some(i);
5158 }
5159 }
5160 None
5161 }
5162
5163 fn render_sticky_entries(
5164 &self,
5165 child: StickyProjectPanelCandidate,
5166 window: &mut Window,
5167 cx: &mut Context<Self>,
5168 ) -> SmallVec<[AnyElement; 8]> {
5169 let project = self.project.read(cx);
5170
5171 let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else {
5172 return SmallVec::new();
5173 };
5174
5175 let Some(visible) = self
5176 .state
5177 .visible_entries
5178 .iter()
5179 .find(|worktree| worktree.worktree_id == worktree_id)
5180 else {
5181 return SmallVec::new();
5182 };
5183
5184 let Some(worktree) = project.worktree_for_id(worktree_id, cx) else {
5185 return SmallVec::new();
5186 };
5187 let worktree = worktree.read(cx).snapshot();
5188
5189 let paths = visible
5190 .index
5191 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
5192
5193 let mut sticky_parents = Vec::new();
5194 let mut current_path = entry_ref.path.clone();
5195
5196 'outer: loop {
5197 if let Some(parent_path) = current_path.parent() {
5198 for ancestor_path in parent_path.ancestors() {
5199 if paths.contains(ancestor_path)
5200 && let Some(parent_entry) = worktree.entry_for_path(ancestor_path)
5201 {
5202 sticky_parents.push(parent_entry.clone());
5203 current_path = parent_entry.path.clone();
5204 continue 'outer;
5205 }
5206 }
5207 }
5208 break 'outer;
5209 }
5210
5211 if sticky_parents.is_empty() {
5212 return SmallVec::new();
5213 }
5214
5215 sticky_parents.reverse();
5216
5217 let panel_settings = ProjectPanelSettings::get_global(cx);
5218 let git_status_enabled = panel_settings.git_status;
5219 let root_name = worktree.root_name();
5220
5221 let git_summaries_by_id = if git_status_enabled {
5222 visible
5223 .entries
5224 .iter()
5225 .map(|e| (e.id, e.git_summary))
5226 .collect::<HashMap<_, _>>()
5227 } else {
5228 Default::default()
5229 };
5230
5231 // already checked if non empty above
5232 let last_item_index = sticky_parents.len() - 1;
5233 sticky_parents
5234 .iter()
5235 .enumerate()
5236 .map(|(index, entry)| {
5237 let git_status = git_summaries_by_id
5238 .get(&entry.id)
5239 .copied()
5240 .unwrap_or_default();
5241 let sticky_details = Some(StickyDetails {
5242 sticky_index: index,
5243 });
5244 let details = self.details_for_entry(
5245 entry,
5246 worktree_id,
5247 root_name,
5248 paths,
5249 git_status,
5250 sticky_details,
5251 window,
5252 cx,
5253 );
5254 self.render_entry(entry.id, details, window, cx)
5255 .when(index == last_item_index, |this| {
5256 let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1);
5257 let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.);
5258 let sticky_shadow = div()
5259 .absolute()
5260 .left_0()
5261 .bottom_neg_1p5()
5262 .h_1p5()
5263 .w_full()
5264 .bg(linear_gradient(
5265 0.,
5266 linear_color_stop(shadow_color_top, 1.),
5267 linear_color_stop(shadow_color_bottom, 0.),
5268 ));
5269 this.child(sticky_shadow)
5270 })
5271 .into_any()
5272 })
5273 .collect()
5274 }
5275}
5276
5277#[derive(Clone)]
5278struct StickyProjectPanelCandidate {
5279 index: usize,
5280 depth: usize,
5281}
5282
5283impl StickyCandidate for StickyProjectPanelCandidate {
5284 fn depth(&self) -> usize {
5285 self.depth
5286 }
5287}
5288
5289fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
5290 const ICON_SIZE_FACTOR: usize = 2;
5291 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
5292 if is_symlink {
5293 item_width += ICON_SIZE_FACTOR;
5294 }
5295 item_width
5296}
5297
5298impl Render for ProjectPanel {
5299 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5300 let has_worktree = !self.state.visible_entries.is_empty();
5301 let project = self.project.read(cx);
5302 let panel_settings = ProjectPanelSettings::get_global(cx);
5303 let indent_size = panel_settings.indent_size;
5304 let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
5305 let show_sticky_entries = {
5306 if panel_settings.sticky_scroll {
5307 let is_scrollable = self.scroll_handle.is_scrollable();
5308 let is_scrolled = self.scroll_handle.offset().y < px(0.);
5309 is_scrollable && is_scrolled
5310 } else {
5311 false
5312 }
5313 };
5314
5315 let is_local = project.is_local();
5316
5317 if has_worktree {
5318 let item_count = self
5319 .state
5320 .visible_entries
5321 .iter()
5322 .map(|worktree| worktree.entries.len())
5323 .sum();
5324
5325 fn handle_drag_move<T: 'static>(
5326 this: &mut ProjectPanel,
5327 e: &DragMoveEvent<T>,
5328 window: &mut Window,
5329 cx: &mut Context<ProjectPanel>,
5330 ) {
5331 if let Some(previous_position) = this.previous_drag_position {
5332 // Refresh cursor only when an actual drag happens,
5333 // because modifiers are not updated when the cursor is not moved.
5334 if e.event.position != previous_position {
5335 this.refresh_drag_cursor_style(&e.event.modifiers, window, cx);
5336 }
5337 }
5338 this.previous_drag_position = Some(e.event.position);
5339
5340 if !e.bounds.contains(&e.event.position) {
5341 this.drag_target_entry = None;
5342 return;
5343 }
5344 this.hover_scroll_task.take();
5345 let panel_height = e.bounds.size.height;
5346 if panel_height <= px(0.) {
5347 return;
5348 }
5349
5350 let event_offset = e.event.position.y - e.bounds.origin.y;
5351 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
5352 let hovered_region_offset = event_offset / panel_height;
5353
5354 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
5355 // These pixels offsets were picked arbitrarily.
5356 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
5357 8.
5358 } else if hovered_region_offset <= 0.15 {
5359 5.
5360 } else if hovered_region_offset >= 0.95 {
5361 -8.
5362 } else if hovered_region_offset >= 0.85 {
5363 -5.
5364 } else {
5365 return;
5366 };
5367 let adjustment = point(px(0.), px(vertical_scroll_offset));
5368 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
5369 loop {
5370 let should_stop_scrolling = this
5371 .update(cx, |this, cx| {
5372 this.hover_scroll_task.as_ref()?;
5373 let handle = this.scroll_handle.0.borrow_mut();
5374 let offset = handle.base_handle.offset();
5375
5376 handle.base_handle.set_offset(offset + adjustment);
5377 cx.notify();
5378 Some(())
5379 })
5380 .ok()
5381 .flatten()
5382 .is_some();
5383 if should_stop_scrolling {
5384 return;
5385 }
5386 cx.background_executor()
5387 .timer(Duration::from_millis(16))
5388 .await;
5389 }
5390 }));
5391 }
5392 h_flex()
5393 .id("project-panel")
5394 .group("project-panel")
5395 .when(panel_settings.drag_and_drop, |this| {
5396 this.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
5397 .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
5398 })
5399 .size_full()
5400 .relative()
5401 .on_modifiers_changed(cx.listener(
5402 |this, event: &ModifiersChangedEvent, window, cx| {
5403 this.refresh_drag_cursor_style(&event.modifiers, window, cx);
5404 },
5405 ))
5406 .key_context(self.dispatch_context(window, cx))
5407 .on_action(cx.listener(Self::scroll_up))
5408 .on_action(cx.listener(Self::scroll_down))
5409 .on_action(cx.listener(Self::scroll_cursor_center))
5410 .on_action(cx.listener(Self::scroll_cursor_top))
5411 .on_action(cx.listener(Self::scroll_cursor_bottom))
5412 .on_action(cx.listener(Self::select_next))
5413 .on_action(cx.listener(Self::select_previous))
5414 .on_action(cx.listener(Self::select_first))
5415 .on_action(cx.listener(Self::select_last))
5416 .on_action(cx.listener(Self::select_parent))
5417 .on_action(cx.listener(Self::select_next_git_entry))
5418 .on_action(cx.listener(Self::select_prev_git_entry))
5419 .on_action(cx.listener(Self::select_next_diagnostic))
5420 .on_action(cx.listener(Self::select_prev_diagnostic))
5421 .on_action(cx.listener(Self::select_next_directory))
5422 .on_action(cx.listener(Self::select_prev_directory))
5423 .on_action(cx.listener(Self::expand_selected_entry))
5424 .on_action(cx.listener(Self::collapse_selected_entry))
5425 .on_action(cx.listener(Self::collapse_all_entries))
5426 .on_action(cx.listener(Self::open))
5427 .on_action(cx.listener(Self::open_permanent))
5428 .on_action(cx.listener(Self::open_split_vertical))
5429 .on_action(cx.listener(Self::open_split_horizontal))
5430 .on_action(cx.listener(Self::confirm))
5431 .on_action(cx.listener(Self::cancel))
5432 .on_action(cx.listener(Self::copy_path))
5433 .on_action(cx.listener(Self::copy_relative_path))
5434 .on_action(cx.listener(Self::new_search_in_directory))
5435 .on_action(cx.listener(Self::unfold_directory))
5436 .on_action(cx.listener(Self::fold_directory))
5437 .on_action(cx.listener(Self::remove_from_project))
5438 .on_action(cx.listener(Self::compare_marked_files))
5439 .when(!project.is_read_only(cx), |el| {
5440 el.on_action(cx.listener(Self::new_file))
5441 .on_action(cx.listener(Self::new_directory))
5442 .on_action(cx.listener(Self::rename))
5443 .on_action(cx.listener(Self::delete))
5444 .on_action(cx.listener(Self::trash))
5445 .on_action(cx.listener(Self::cut))
5446 .on_action(cx.listener(Self::copy))
5447 .on_action(cx.listener(Self::paste))
5448 .on_action(cx.listener(Self::duplicate))
5449 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
5450 if event.click_count() > 1
5451 && let Some(entry_id) = this.state.last_worktree_root_id
5452 {
5453 let project = this.project.read(cx);
5454
5455 let worktree_id = if let Some(worktree) =
5456 project.worktree_for_entry(entry_id, cx)
5457 {
5458 worktree.read(cx).id()
5459 } else {
5460 return;
5461 };
5462
5463 this.state.selection = Some(SelectedEntry {
5464 worktree_id,
5465 entry_id,
5466 });
5467
5468 this.new_file(&NewFile, window, cx);
5469 }
5470 }))
5471 })
5472 .when(project.is_local(), |el| {
5473 el.on_action(cx.listener(Self::reveal_in_finder))
5474 .on_action(cx.listener(Self::open_system))
5475 .on_action(cx.listener(Self::open_in_terminal))
5476 })
5477 .when(project.is_via_remote_server(), |el| {
5478 el.on_action(cx.listener(Self::open_in_terminal))
5479 })
5480 .track_focus(&self.focus_handle(cx))
5481 .child(
5482 v_flex()
5483 .child(
5484 uniform_list("entries", item_count, {
5485 cx.processor(|this, range: Range<usize>, window, cx| {
5486 this.rendered_entries_len = range.end - range.start;
5487 let mut items = Vec::with_capacity(this.rendered_entries_len);
5488 this.for_each_visible_entry(
5489 range,
5490 window,
5491 cx,
5492 |id, details, window, cx| {
5493 items.push(this.render_entry(id, details, window, cx));
5494 },
5495 );
5496 items
5497 })
5498 })
5499 .when(show_indent_guides, |list| {
5500 list.with_decoration(
5501 ui::indent_guides(
5502 px(indent_size),
5503 IndentGuideColors::panel(cx),
5504 )
5505 .with_compute_indents_fn(
5506 cx.entity(),
5507 |this, range, window, cx| {
5508 let mut items =
5509 SmallVec::with_capacity(range.end - range.start);
5510 this.iter_visible_entries(
5511 range,
5512 window,
5513 cx,
5514 |entry, _, entries, _, _| {
5515 let (depth, _) =
5516 Self::calculate_depth_and_difference(
5517 entry, entries,
5518 );
5519 items.push(depth);
5520 },
5521 );
5522 items
5523 },
5524 )
5525 .on_click(cx.listener(
5526 |this,
5527 active_indent_guide: &IndentGuideLayout,
5528 window,
5529 cx| {
5530 if window.modifiers().secondary() {
5531 let ix = active_indent_guide.offset.y;
5532 let Some((target_entry, worktree)) = maybe!({
5533 let (worktree_id, entry) =
5534 this.entry_at_index(ix)?;
5535 let worktree = this
5536 .project
5537 .read(cx)
5538 .worktree_for_id(worktree_id, cx)?;
5539 let target_entry = worktree
5540 .read(cx)
5541 .entry_for_path(&entry.path.parent()?)?;
5542 Some((target_entry, worktree))
5543 }) else {
5544 return;
5545 };
5546
5547 this.collapse_entry(
5548 target_entry.clone(),
5549 worktree,
5550 window,
5551 cx,
5552 );
5553 }
5554 },
5555 ))
5556 .with_render_fn(
5557 cx.entity(),
5558 move |this, params, _, cx| {
5559 const LEFT_OFFSET: Pixels = px(14.);
5560 const PADDING_Y: Pixels = px(4.);
5561 const HITBOX_OVERDRAW: Pixels = px(3.);
5562
5563 let active_indent_guide_index = this
5564 .find_active_indent_guide(
5565 ¶ms.indent_guides,
5566 cx,
5567 );
5568
5569 let indent_size = params.indent_size;
5570 let item_height = params.item_height;
5571
5572 params
5573 .indent_guides
5574 .into_iter()
5575 .enumerate()
5576 .map(|(idx, layout)| {
5577 let offset = if layout.continues_offscreen {
5578 px(0.)
5579 } else {
5580 PADDING_Y
5581 };
5582 let bounds = Bounds::new(
5583 point(
5584 layout.offset.x * indent_size
5585 + LEFT_OFFSET,
5586 layout.offset.y * item_height + offset,
5587 ),
5588 size(
5589 px(1.),
5590 layout.length * item_height
5591 - offset * 2.,
5592 ),
5593 );
5594 ui::RenderedIndentGuide {
5595 bounds,
5596 layout,
5597 is_active: Some(idx)
5598 == active_indent_guide_index,
5599 hitbox: Some(Bounds::new(
5600 point(
5601 bounds.origin.x - HITBOX_OVERDRAW,
5602 bounds.origin.y,
5603 ),
5604 size(
5605 bounds.size.width
5606 + HITBOX_OVERDRAW * 2.,
5607 bounds.size.height,
5608 ),
5609 )),
5610 }
5611 })
5612 .collect()
5613 },
5614 ),
5615 )
5616 })
5617 .when(show_sticky_entries, |list| {
5618 let sticky_items = ui::sticky_items(
5619 cx.entity(),
5620 |this, range, window, cx| {
5621 let mut items =
5622 SmallVec::with_capacity(range.end - range.start);
5623 this.iter_visible_entries(
5624 range,
5625 window,
5626 cx,
5627 |entry, index, entries, _, _| {
5628 let (depth, _) =
5629 Self::calculate_depth_and_difference(
5630 entry, entries,
5631 );
5632 let candidate =
5633 StickyProjectPanelCandidate { index, depth };
5634 items.push(candidate);
5635 },
5636 );
5637 items
5638 },
5639 |this, marker_entry, window, cx| {
5640 let sticky_entries =
5641 this.render_sticky_entries(marker_entry, window, cx);
5642 this.sticky_items_count = sticky_entries.len();
5643 sticky_entries
5644 },
5645 );
5646 list.with_decoration(if show_indent_guides {
5647 sticky_items.with_decoration(
5648 ui::indent_guides(
5649 px(indent_size),
5650 IndentGuideColors::panel(cx),
5651 )
5652 .with_render_fn(
5653 cx.entity(),
5654 move |_, params, _, _| {
5655 const LEFT_OFFSET: Pixels = px(14.);
5656
5657 let indent_size = params.indent_size;
5658 let item_height = params.item_height;
5659
5660 params
5661 .indent_guides
5662 .into_iter()
5663 .map(|layout| {
5664 let bounds = Bounds::new(
5665 point(
5666 layout.offset.x * indent_size
5667 + LEFT_OFFSET,
5668 layout.offset.y * item_height,
5669 ),
5670 size(
5671 px(1.),
5672 layout.length * item_height,
5673 ),
5674 );
5675 ui::RenderedIndentGuide {
5676 bounds,
5677 layout,
5678 is_active: false,
5679 hitbox: None,
5680 }
5681 })
5682 .collect()
5683 },
5684 ),
5685 )
5686 } else {
5687 sticky_items
5688 })
5689 })
5690 .with_sizing_behavior(ListSizingBehavior::Infer)
5691 .with_horizontal_sizing_behavior(
5692 ListHorizontalSizingBehavior::Unconstrained,
5693 )
5694 .with_width_from_item(self.state.max_width_item_index)
5695 .track_scroll(self.scroll_handle.clone()),
5696 )
5697 .child(
5698 div()
5699 .id("project-panel-blank-area")
5700 .block_mouse_except_scroll()
5701 .flex_grow()
5702 .when(
5703 self.drag_target_entry.as_ref().is_some_and(
5704 |entry| match entry {
5705 DragTarget::Background => true,
5706 DragTarget::Entry {
5707 highlight_entry_id, ..
5708 } => self.state.last_worktree_root_id.is_some_and(
5709 |root_id| *highlight_entry_id == root_id,
5710 ),
5711 },
5712 ),
5713 |div| div.bg(cx.theme().colors().drop_target_background),
5714 )
5715 .on_drag_move::<ExternalPaths>(cx.listener(
5716 move |this, event: &DragMoveEvent<ExternalPaths>, _, _| {
5717 let Some(_last_root_id) = this.state.last_worktree_root_id
5718 else {
5719 return;
5720 };
5721 if event.bounds.contains(&event.event.position) {
5722 this.drag_target_entry = Some(DragTarget::Background);
5723 } else {
5724 if this.drag_target_entry.as_ref().is_some_and(|e| {
5725 matches!(e, DragTarget::Background)
5726 }) {
5727 this.drag_target_entry = None;
5728 }
5729 }
5730 },
5731 ))
5732 .on_drag_move::<DraggedSelection>(cx.listener(
5733 move |this, event: &DragMoveEvent<DraggedSelection>, _, cx| {
5734 let Some(last_root_id) = this.state.last_worktree_root_id
5735 else {
5736 return;
5737 };
5738 if event.bounds.contains(&event.event.position) {
5739 let drag_state = event.drag(cx);
5740 if this.should_highlight_background_for_selection_drag(
5741 &drag_state,
5742 last_root_id,
5743 cx,
5744 ) {
5745 this.drag_target_entry =
5746 Some(DragTarget::Background);
5747 }
5748 } else {
5749 if this.drag_target_entry.as_ref().is_some_and(|e| {
5750 matches!(e, DragTarget::Background)
5751 }) {
5752 this.drag_target_entry = None;
5753 }
5754 }
5755 },
5756 ))
5757 .on_drop(cx.listener(
5758 move |this, external_paths: &ExternalPaths, window, cx| {
5759 this.drag_target_entry = None;
5760 this.hover_scroll_task.take();
5761 if let Some(entry_id) = this.state.last_worktree_root_id {
5762 this.drop_external_files(
5763 external_paths.paths(),
5764 entry_id,
5765 window,
5766 cx,
5767 );
5768 }
5769 cx.stop_propagation();
5770 },
5771 ))
5772 .on_drop(cx.listener(
5773 move |this, selections: &DraggedSelection, window, cx| {
5774 this.drag_target_entry = None;
5775 this.hover_scroll_task.take();
5776 if let Some(entry_id) = this.state.last_worktree_root_id {
5777 this.drag_onto(selections, entry_id, false, window, cx);
5778 }
5779 cx.stop_propagation();
5780 },
5781 ))
5782 .on_click(cx.listener(|this, event, window, cx| {
5783 if matches!(event, gpui::ClickEvent::Keyboard(_)) {
5784 return;
5785 }
5786 cx.stop_propagation();
5787 this.state.selection = None;
5788 this.marked_entries.clear();
5789 this.focus_handle(cx).focus(window);
5790 }))
5791 .on_mouse_down(
5792 MouseButton::Right,
5793 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
5794 // When deploying the context menu anywhere below the last project entry,
5795 // act as if the user clicked the root of the last worktree.
5796 if let Some(entry_id) = this.state.last_worktree_root_id {
5797 this.deploy_context_menu(
5798 event.position,
5799 entry_id,
5800 window,
5801 cx,
5802 );
5803 }
5804 }),
5805 ),
5806 )
5807 .size_full(),
5808 )
5809 .custom_scrollbars(
5810 Scrollbars::for_settings::<ProjectPanelSettings>()
5811 .tracked_scroll_handle(self.scroll_handle.clone())
5812 .with_track_along(
5813 ScrollAxes::Horizontal,
5814 cx.theme().colors().panel_background,
5815 )
5816 .notify_content(),
5817 window,
5818 cx,
5819 )
5820 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
5821 deferred(
5822 anchored()
5823 .position(*position)
5824 .anchor(gpui::Corner::TopLeft)
5825 .child(menu.clone()),
5826 )
5827 .with_priority(3)
5828 }))
5829 } else {
5830 let focus_handle = self.focus_handle(cx);
5831
5832 v_flex()
5833 .id("empty-project_panel")
5834 .p_4()
5835 .size_full()
5836 .items_center()
5837 .justify_center()
5838 .gap_1()
5839 .track_focus(&self.focus_handle(cx))
5840 .child(
5841 Button::new("open_project", "Open Project")
5842 .full_width()
5843 .key_binding(KeyBinding::for_action_in(
5844 &workspace::Open,
5845 &focus_handle,
5846 window,
5847 cx,
5848 ))
5849 .on_click(cx.listener(|this, _, window, cx| {
5850 this.workspace
5851 .update(cx, |_, cx| {
5852 window.dispatch_action(workspace::Open.boxed_clone(), cx);
5853 })
5854 .log_err();
5855 })),
5856 )
5857 .child(
5858 h_flex()
5859 .w_1_2()
5860 .gap_2()
5861 .child(Divider::horizontal())
5862 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
5863 .child(Divider::horizontal()),
5864 )
5865 .child(
5866 Button::new("clone_repo", "Clone Repository")
5867 .full_width()
5868 .on_click(cx.listener(|this, _, window, cx| {
5869 this.workspace
5870 .update(cx, |_, cx| {
5871 window.dispatch_action(git::Clone.boxed_clone(), cx);
5872 })
5873 .log_err();
5874 })),
5875 )
5876 .when(is_local, |div| {
5877 div.when(panel_settings.drag_and_drop, |div| {
5878 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
5879 style.bg(cx.theme().colors().drop_target_background)
5880 })
5881 .on_drop(cx.listener(
5882 move |this, external_paths: &ExternalPaths, window, cx| {
5883 this.drag_target_entry = None;
5884 this.hover_scroll_task.take();
5885 if let Some(task) = this
5886 .workspace
5887 .update(cx, |workspace, cx| {
5888 workspace.open_workspace_for_paths(
5889 true,
5890 external_paths.paths().to_owned(),
5891 window,
5892 cx,
5893 )
5894 })
5895 .log_err()
5896 {
5897 task.detach_and_log_err(cx);
5898 }
5899 cx.stop_propagation();
5900 },
5901 ))
5902 })
5903 })
5904 }
5905 }
5906}
5907
5908impl Render for DraggedProjectEntryView {
5909 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5910 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
5911 h_flex()
5912 .font(ui_font)
5913 .pl(self.click_offset.x + px(12.))
5914 .pt(self.click_offset.y + px(12.))
5915 .child(
5916 div()
5917 .flex()
5918 .gap_1()
5919 .items_center()
5920 .py_1()
5921 .px_2()
5922 .rounded_lg()
5923 .bg(cx.theme().colors().background)
5924 .map(|this| {
5925 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
5926 this.child(Label::new(format!("{} entries", self.selections.len())))
5927 } else {
5928 this.child(if let Some(icon) = &self.icon {
5929 div().child(Icon::from_path(icon.clone()))
5930 } else {
5931 div()
5932 })
5933 .child(Label::new(self.filename.clone()))
5934 }
5935 }),
5936 )
5937 }
5938}
5939
5940impl EventEmitter<Event> for ProjectPanel {}
5941
5942impl EventEmitter<PanelEvent> for ProjectPanel {}
5943
5944impl Panel for ProjectPanel {
5945 fn position(&self, _: &Window, cx: &App) -> DockPosition {
5946 match ProjectPanelSettings::get_global(cx).dock {
5947 DockSide::Left => DockPosition::Left,
5948 DockSide::Right => DockPosition::Right,
5949 }
5950 }
5951
5952 fn position_is_valid(&self, position: DockPosition) -> bool {
5953 matches!(position, DockPosition::Left | DockPosition::Right)
5954 }
5955
5956 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
5957 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
5958 let dock = match position {
5959 DockPosition::Left | DockPosition::Bottom => DockSide::Left,
5960 DockPosition::Right => DockSide::Right,
5961 };
5962 settings.project_panel.get_or_insert_default().dock = Some(dock);
5963 });
5964 }
5965
5966 fn size(&self, _: &Window, cx: &App) -> Pixels {
5967 self.width
5968 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
5969 }
5970
5971 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
5972 self.width = size;
5973 cx.notify();
5974 cx.defer_in(window, |this, _, cx| {
5975 this.serialize(cx);
5976 });
5977 }
5978
5979 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
5980 ProjectPanelSettings::get_global(cx)
5981 .button
5982 .then_some(IconName::FileTree)
5983 }
5984
5985 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
5986 Some("Project Panel")
5987 }
5988
5989 fn toggle_action(&self) -> Box<dyn Action> {
5990 Box::new(ToggleFocus)
5991 }
5992
5993 fn persistent_name() -> &'static str {
5994 "Project Panel"
5995 }
5996
5997 fn starts_open(&self, _: &Window, cx: &App) -> bool {
5998 if !ProjectPanelSettings::get_global(cx).starts_open {
5999 return false;
6000 }
6001
6002 let project = &self.project.read(cx);
6003 project.visible_worktrees(cx).any(|tree| {
6004 tree.read(cx)
6005 .root_entry()
6006 .is_some_and(|entry| entry.is_dir())
6007 })
6008 }
6009
6010 fn activation_priority(&self) -> u32 {
6011 0
6012 }
6013}
6014
6015impl Focusable for ProjectPanel {
6016 fn focus_handle(&self, _cx: &App) -> FocusHandle {
6017 self.focus_handle.clone()
6018 }
6019}
6020
6021impl ClipboardEntry {
6022 fn is_cut(&self) -> bool {
6023 matches!(self, Self::Cut { .. })
6024 }
6025
6026 fn items(&self) -> &BTreeSet<SelectedEntry> {
6027 match self {
6028 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
6029 }
6030 }
6031
6032 fn into_copy_entry(self) -> Self {
6033 match self {
6034 ClipboardEntry::Copied(_) => self,
6035 ClipboardEntry::Cut(entries) => ClipboardEntry::Copied(entries),
6036 }
6037 }
6038}
6039
6040fn cmp<T: AsRef<Entry>>(lhs: T, rhs: T) -> cmp::Ordering {
6041 let entry_a = lhs.as_ref();
6042 let entry_b = rhs.as_ref();
6043 util::paths::compare_rel_paths(
6044 (&entry_a.path, entry_a.is_file()),
6045 (&entry_b.path, entry_b.is_file()),
6046 )
6047}
6048
6049pub fn sort_worktree_entries(entries: &mut [impl AsRef<Entry>]) {
6050 entries.sort_by(|lhs, rhs| cmp(lhs, rhs));
6051}
6052
6053pub fn par_sort_worktree_entries(entries: &mut Vec<GitEntry>) {
6054 entries.par_sort_by(|lhs, rhs| cmp(lhs, rhs));
6055}
6056
6057#[cfg(test)]
6058mod project_panel_tests;