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