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