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