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