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