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