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