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