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