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