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