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