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