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