Path: blob/master/editor/project_manager/project_list.cpp
20843 views
/**************************************************************************/1/* project_list.cpp */2/**************************************************************************/3/* This file is part of: */4/* GODOT ENGINE */5/* https://godotengine.org */6/**************************************************************************/7/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */8/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */9/* */10/* Permission is hereby granted, free of charge, to any person obtaining */11/* a copy of this software and associated documentation files (the */12/* "Software"), to deal in the Software without restriction, including */13/* without limitation the rights to use, copy, modify, merge, publish, */14/* distribute, sublicense, and/or sell copies of the Software, and to */15/* permit persons to whom the Software is furnished to do so, subject to */16/* the following conditions: */17/* */18/* The above copyright notice and this permission notice shall be */19/* included in all copies or substantial portions of the Software. */20/* */21/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */22/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */23/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */24/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */25/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */26/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */27/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */28/**************************************************************************/2930#include "project_list.h"3132#include "core/config/project_settings.h"33#include "core/input/input.h"34#include "core/io/dir_access.h"35#include "core/os/time.h"36#include "core/version.h"37#include "editor/editor_string_names.h"38#include "editor/file_system/editor_paths.h"39#include "editor/project_manager/project_manager.h"40#include "editor/project_manager/project_tag.h"41#include "editor/settings/editor_settings.h"42#include "editor/themes/editor_scale.h"43#include "scene/gui/button.h"44#include "scene/gui/dialogs.h"45#include "scene/gui/label.h"46#include "scene/gui/line_edit.h"47#include "scene/gui/popup_menu.h"48#include "scene/gui/progress_bar.h"49#include "scene/gui/texture_button.h"50#include "scene/gui/texture_rect.h"51#include "scene/resources/image_texture.h"5253void ProjectListItemControl::_notification(int p_what) {54switch (p_what) {55case NOTIFICATION_THEME_CHANGED: {56if (icon_needs_reload) {57// The project icon may not be loaded by the time the control is displayed,58// so use a loading placeholder.59project_icon->set_texture(get_editor_theme_icon(SNAME("ProjectIconLoading")));60}6162project_title->begin_bulk_theme_override();63project_title->add_theme_font_override(SceneStringName(font), get_theme_font(SNAME("title"), EditorStringName(EditorFonts)));64project_title->add_theme_font_size_override(SceneStringName(font_size), get_theme_font_size(SNAME("title_size"), EditorStringName(EditorFonts)));65project_title->add_theme_color_override(SceneStringName(font_color), get_theme_color(SceneStringName(font_color), SNAME("ProjectList")));66project_title->end_bulk_theme_override();6768project_path->add_theme_color_override(SceneStringName(font_color), get_theme_color(SceneStringName(font_color), SNAME("ProjectList")));69project_unsupported_features->set_texture(get_editor_theme_icon(SNAME("NodeWarning")));7071favorite_focus_color = get_theme_color(SNAME("accent_color"), EditorStringName(Editor));72_update_favorite_button_focus_color();73if (is_favorite) {74favorite_button->set_texture_normal(get_editor_theme_icon(SNAME("Favorites")));75} else {76favorite_button->set_texture_normal(get_editor_theme_icon(SNAME("Unfavorite")));77}7879if (project_is_missing) {80explore_button->set_button_icon(get_editor_theme_icon(SNAME("FileBroken")));81#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)82} else {83explore_button->set_button_icon(get_editor_theme_icon(SNAME("Load")));84#endif85}8687if (touch_menu_button) {88touch_menu_button->set_button_icon(get_editor_theme_icon(SNAME("GuiTabMenuHl")));89}90} break;9192case NOTIFICATION_MOUSE_ENTER: {93is_hovering = true;94queue_redraw();95queue_accessibility_update();96} break;9798case NOTIFICATION_MOUSE_EXIT: {99is_hovering = false;100queue_redraw();101queue_accessibility_update();102} break;103104case NOTIFICATION_ACCESSIBILITY_UPDATE: {105RID ae = get_accessibility_element();106ERR_FAIL_COND(ae.is_null());107108DisplayServer::get_singleton()->accessibility_update_set_role(ae, DisplayServer::AccessibilityRole::ROLE_LIST_BOX_OPTION);109DisplayServer::get_singleton()->accessibility_update_set_name(ae, TTR("Project") + " " + project_title->get_text());110DisplayServer::get_singleton()->accessibility_update_set_value(ae, project_title->get_text());111112DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_CLICK, callable_mp(this, &ProjectListItemControl::_accessibility_action_open));113DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_SCROLL_INTO_VIEW, callable_mp(this, &ProjectListItemControl::_accessibility_action_scroll_into_view));114DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_FOCUS, callable_mp(this, &ProjectListItemControl::_accessibility_action_focus));115DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_BLUR, callable_mp(this, &ProjectListItemControl::_accessibility_action_blur));116117ProjectList *pl = get_list();118if (pl) {119DisplayServer::get_singleton()->accessibility_update_set_list_item_index(ae, pl->get_index(this));120}121DisplayServer::get_singleton()->accessibility_update_set_list_item_level(ae, 0);122DisplayServer::get_singleton()->accessibility_update_set_list_item_selected(ae, is_selected);123} break;124125case NOTIFICATION_FOCUS_ENTER: {126ProjectList *pl = get_list();127if (pl) {128int idx = pl->get_index(this);129if (idx >= 0) {130// has_focus(true) is false on mouse-initiated focus, true on keyboard navigation.131pl->select_project(idx, !has_focus(true));132133pl->emit_signal(SNAME(ProjectList::SIGNAL_SELECTION_CHANGED));134}135}136} break;137138case NOTIFICATION_DRAW: {139if (is_selected && is_hovering) {140draw_style_box(get_theme_stylebox(SNAME("hover_pressed"), SNAME("ProjectList")), Rect2(Point2(), get_size()));141} else if (is_selected) {142draw_style_box(get_theme_stylebox(SNAME("selected"), SNAME("ProjectList")), Rect2(Point2(), get_size()));143} else if (is_hovering) {144draw_style_box(get_theme_stylebox(SNAME("hovered"), SNAME("ProjectList")), Rect2(Point2(), get_size()));145}146// Due to how this control works, we can't rely on the built-in way of checking for focus visibility.147if (has_focus() && !is_focus_hidden) {148draw_style_box(get_theme_stylebox(SNAME("focus"), SNAME("ProjectList")), Rect2(Point2(), get_size()));149}150151draw_line(Point2(0, get_size().y + 1), Point2(get_size().x, get_size().y + 1), get_theme_color(SNAME("guide_color"), SNAME("ProjectList")));152} break;153}154}155156ProjectList *ProjectListItemControl::get_list() const {157if (!is_inside_tree()) {158return nullptr;159}160ProjectList *pl = Object::cast_to<ProjectList>(get_parent()->get_parent());161return pl;162}163164void ProjectListItemControl::_accessibility_action_scroll_into_view(const Variant &p_data) {165ProjectList *pl = get_list();166if (pl) {167int idx = pl->get_index(this);168if (idx >= 0) {169pl->ensure_project_visible(idx);170}171}172}173174void ProjectListItemControl::_accessibility_action_open(const Variant &p_data) {175ProjectList *pl = get_list();176if (pl && !pl->project_opening_initiated) {177pl->emit_signal(SNAME(ProjectList::SIGNAL_PROJECT_ASK_OPEN));178}179}180181void ProjectListItemControl::_accessibility_action_focus(const Variant &p_data) {182ProjectList *pl = get_list();183if (pl) {184int idx = pl->get_index(this);185if (idx >= 0) {186pl->ensure_project_visible(idx);187pl->select_project(idx);188}189}190}191192void ProjectListItemControl::_accessibility_action_blur(const Variant &p_data) {193ProjectList *pl = get_list();194if (pl) {195int idx = pl->get_index(this);196if (idx >= 0) {197pl->ensure_project_visible(idx);198pl->deselect_project(idx);199}200}201}202void ProjectListItemControl::_update_favorite_button_focus_color() {203if (favorite_button->has_focus()) {204favorite_button->set_self_modulate(favorite_focus_color);205} else {206favorite_button->set_self_modulate(Color(1.0, 1.0, 1.0, 1.0));207}208}209210void ProjectListItemControl::_favorite_button_pressed() {211emit_signal(SNAME("favorite_pressed"));212}213214void ProjectListItemControl::_explore_button_pressed() {215emit_signal(SNAME("explore_pressed"));216}217218void ProjectListItemControl::_request_menu() {219emit_signal(SNAME("request_menu"), Vector2(touch_menu_button->get_position()));220}221222void ProjectListItemControl::set_project_title(const String &p_title) {223project_title->set_text(p_title);224project_title->set_accessibility_name(TTRC("Project Name"));225queue_accessibility_update();226}227228void ProjectListItemControl::set_project_path(const String &p_path) {229project_path->set_text(p_path);230project_path->set_accessibility_name(TTRC("Project Path"));231queue_accessibility_update();232}233234void ProjectListItemControl::set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list) {235for (const String &tag : p_tags) {236ProjectTag *tag_control = memnew(ProjectTag(tag));237tag_container->add_child(tag_control);238tag_control->connect_button_to(callable_mp(p_parent_list, &ProjectList::add_search_tag).bind(tag));239}240}241242void ProjectListItemControl::set_project_icon(const Ref<Texture2D> &p_icon) {243icon_needs_reload = false;244245// The default project icon is 128×128 to look crisp on hiDPI displays,246// but we want the actual displayed size to be 64×64 on loDPI displays.247project_icon->set_expand_mode(TextureRect::EXPAND_IGNORE_SIZE);248project_icon->set_custom_minimum_size(Size2(64, 64) * EDSCALE);249project_icon->set_stretch_mode(TextureRect::STRETCH_KEEP_ASPECT_CENTERED);250251project_icon->set_texture(p_icon);252}253254void ProjectListItemControl::set_last_edited_info(const String &p_info) {255last_edited_info->set_text(p_info);256}257258void ProjectListItemControl::set_project_version(const String &p_info) {259project_version->set_text(p_info);260}261262void ProjectListItemControl::set_unsupported_features(PackedStringArray p_features) {263if (p_features.size() > 0) {264String tooltip_text = "";265for (int i = 0; i < p_features.size(); i++) {266if (ProjectList::project_feature_looks_like_version(p_features[i])) {267PackedStringArray project_version_split = p_features[i].split(".");268int project_version_major = 0, project_version_minor = 0;269if (project_version_split.size() >= 2) {270project_version_major = project_version_split[0].to_int();271project_version_minor = project_version_split[1].to_int();272}273if (GODOT_VERSION_MAJOR != project_version_major || GODOT_VERSION_MINOR <= project_version_minor) {274// Don't show a warning if the project was last edited in a previous minor version.275tooltip_text += TTR("This project was last edited in a different Godot version: ") + p_features[i] + "\n";276}277p_features.remove_at(i);278i--;279}280}281if (p_features.size() > 0) {282String unsupported_features_str = String(", ").join(p_features);283tooltip_text += TTR("This project uses features unsupported by the current build:") + "\n" + unsupported_features_str;284}285if (tooltip_text.is_empty()) {286return;287}288project_version->set_tooltip_text(tooltip_text);289project_unsupported_features->set_focus_mode(FOCUS_ACCESSIBILITY);290project_unsupported_features->set_tooltip_text(tooltip_text);291project_unsupported_features->show();292} else {293project_unsupported_features->hide();294}295}296297bool ProjectListItemControl::should_load_project_icon() const {298return icon_needs_reload;299}300301void ProjectListItemControl::set_selected(bool p_selected, bool p_hide_focus) {302is_selected = p_selected;303is_focus_hidden = is_selected && p_hide_focus;304queue_redraw();305queue_accessibility_update();306}307308void ProjectListItemControl::set_is_favorite(bool p_favorite) {309is_favorite = p_favorite;310if (p_favorite) {311favorite_button->set_texture_normal(get_editor_theme_icon(SNAME("Favorites")));312favorite_button->set_accessibility_name(TTRC("Remove from Favorites"));313} else {314favorite_button->set_texture_normal(get_editor_theme_icon(SNAME("Unfavorite")));315favorite_button->set_accessibility_name(TTRC("Add to Favorites"));316}317}318319void ProjectListItemControl::set_is_missing(bool p_missing) {320project_is_missing = p_missing;321322if (project_is_missing) {323project_icon->set_modulate(Color(1, 1, 1, 0.5));324325explore_button->set_button_icon(get_editor_theme_icon(SNAME("FileBroken")));326explore_button->set_tooltip_text(TTRC("Error: Project is missing on the filesystem."));327} else {328#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)329explore_button->set_button_icon(get_editor_theme_icon(SNAME("Load")));330explore_button->set_tooltip_text(TTRC("Show in File Manager"));331#else332// Opening the system file manager is not supported on the Android and web editors.333explore_button->hide();334#endif335}336}337338void ProjectListItemControl::set_is_grayed(bool p_grayed) {339if (p_grayed) {340main_vbox->set_modulate(Color(1, 1, 1, 0.5));341// Don't make the icon less prominent if the parent is already grayed out.342explore_button->set_modulate(Color(1, 1, 1, 1.0));343} else {344main_vbox->set_modulate(Color(1, 1, 1, 1.0));345explore_button->set_modulate(Color(1, 1, 1, 0.5));346}347}348349void ProjectListItemControl::_bind_methods() {350ADD_SIGNAL(MethodInfo("favorite_pressed"));351ADD_SIGNAL(MethodInfo("explore_pressed"));352ADD_SIGNAL(MethodInfo("request_menu"));353}354355ProjectListItemControl::ProjectListItemControl() {356set_focus_mode(FocusMode::FOCUS_ALL);357set_auto_translate_mode(AUTO_TRANSLATE_MODE_DISABLED);358359// Left spacer.360add_child(memnew(Control));361362VBoxContainer *favorite_box = memnew(VBoxContainer);363favorite_box->set_alignment(BoxContainer::ALIGNMENT_CENTER);364add_child(favorite_box);365366favorite_button = memnew(TextureButton);367favorite_button->set_name("FavoriteButton");368favorite_button->set_tooltip_text(TTRC("Toggle Favorite"));369favorite_button->set_auto_translate_mode(AUTO_TRANSLATE_MODE_ALWAYS);370// This makes the project's "hover" style display correctly when hovering the favorite icon.371favorite_button->set_mouse_filter(MOUSE_FILTER_PASS);372favorite_box->add_child(favorite_button);373favorite_button->connect(SceneStringName(pressed), callable_mp(this, &ProjectListItemControl::_favorite_button_pressed));374favorite_button->connect(SceneStringName(focus_entered), callable_mp(this, &ProjectListItemControl::_update_favorite_button_focus_color));375favorite_button->connect(SceneStringName(focus_exited), callable_mp(this, &ProjectListItemControl::_update_favorite_button_focus_color));376377project_icon = memnew(TextureRect);378project_icon->set_name("ProjectIcon");379project_icon->set_v_size_flags(SIZE_SHRINK_CENTER);380add_child(project_icon);381382main_vbox = memnew(VBoxContainer);383main_vbox->set_h_size_flags(Control::SIZE_EXPAND_FILL);384add_child(main_vbox);385386Control *ec = memnew(Control);387ec->set_custom_minimum_size(Size2(0, 1));388ec->set_mouse_filter(MOUSE_FILTER_PASS);389main_vbox->add_child(ec);390391// Top half, title, tags and unsupported features labels.392{393HBoxContainer *title_hb = memnew(HBoxContainer);394main_vbox->add_child(title_hb);395396project_title = memnew(Label);397project_title->set_focus_mode(FOCUS_ACCESSIBILITY);398project_title->set_name("ProjectName");399project_title->set_h_size_flags(Control::SIZE_EXPAND_FILL);400project_title->set_clip_text(true);401title_hb->add_child(project_title);402403tag_container = memnew(HBoxContainer);404title_hb->add_child(tag_container);405}406407// Bottom half, containing the path and view folder button.408{409HBoxContainer *path_hb = memnew(HBoxContainer);410path_hb->set_h_size_flags(Control::SIZE_EXPAND_FILL);411main_vbox->add_child(path_hb);412413explore_button = memnew(Button);414explore_button->set_name("ExploreButton");415explore_button->set_tooltip_auto_translate_mode(AUTO_TRANSLATE_MODE_ALWAYS);416explore_button->set_mouse_filter(MOUSE_FILTER_PASS);417explore_button->set_tooltip_text(TTRC("Open in file manager"));418explore_button->set_flat(true);419path_hb->add_child(explore_button);420explore_button->connect(SceneStringName(pressed), callable_mp(this, &ProjectListItemControl::_explore_button_pressed));421422project_path = memnew(Label);423project_path->set_name("ProjectPath");424project_path->set_focus_mode(FOCUS_ACCESSIBILITY);425project_path->set_structured_text_bidi_override(TextServer::STRUCTURED_TEXT_FILE);426project_path->set_clip_text(true);427project_path->set_h_size_flags(Control::SIZE_EXPAND_FILL);428project_path->set_modulate(Color(1, 1, 1, 0.5));429path_hb->add_child(project_path);430431project_unsupported_features = memnew(TextureRect);432project_unsupported_features->set_name("ProjectUnsupportedFeatures");433project_unsupported_features->set_stretch_mode(TextureRect::STRETCH_KEEP_CENTERED);434path_hb->add_child(project_unsupported_features);435project_unsupported_features->hide();436437project_version = memnew(Label);438project_version->set_focus_mode(FOCUS_ACCESSIBILITY);439project_version->set_name("ProjectVersion");440project_version->set_mouse_filter(Control::MOUSE_FILTER_PASS);441path_hb->add_child(project_version);442443last_edited_info = memnew(Label);444last_edited_info->set_focus_mode(FOCUS_ACCESSIBILITY);445last_edited_info->set_name("LastEditedInfo");446last_edited_info->set_mouse_filter(Control::MOUSE_FILTER_PASS);447last_edited_info->set_tooltip_auto_translate_mode(AUTO_TRANSLATE_MODE_ALWAYS);448last_edited_info->set_tooltip_text(TTRC("Last edited timestamp"));449last_edited_info->set_modulate(Color(1, 1, 1, 0.5));450path_hb->add_child(last_edited_info);451}452453if (DisplayServer::get_singleton()->is_touchscreen_available()) {454touch_menu_button = memnew(Button);455touch_menu_button->set_theme_type_variation(SceneStringName(FlatButton));456touch_menu_button->set_v_size_flags(SIZE_SHRINK_CENTER);457add_child(touch_menu_button);458touch_menu_button->set_mouse_filter(MOUSE_FILTER_PASS);459touch_menu_button->connect(SceneStringName(pressed), callable_mp(this, &ProjectListItemControl::_request_menu));460}461462// Right spacer.463add_child(memnew(Control));464}465466struct ProjectListComparator {467ProjectList::FilterOption order_option = ProjectList::FilterOption::EDIT_DATE;468469// operator<470_FORCE_INLINE_ bool operator()(const ProjectList::Item &a, const ProjectList::Item &b) const {471if (a.favorite && !b.favorite) {472return true;473}474if (b.favorite && !a.favorite) {475return false;476}477switch (order_option) {478case ProjectList::PATH:479return a.path < b.path;480case ProjectList::EDIT_DATE:481return a.last_edited > b.last_edited;482case ProjectList::TAGS:483return a.tag_sort_string < b.tag_sort_string;484default:485return a.project_name < b.project_name;486}487}488};489490// Helpers.491492bool ProjectList::project_feature_looks_like_version(const String &p_feature) {493return p_feature.contains_char('.') && p_feature.substr(0, 3).is_numeric();494}495496// Notifications.497498void ProjectList::_notification(int p_what) {499switch (p_what) {500case NOTIFICATION_TRANSLATION_CHANGED: {501if (is_ready()) {502// FIXME: Technically this only needs to update some dynamic texts, not the whole list.503update_project_list();504}505} break;506507case NOTIFICATION_THEME_CHANGED: {508if (project_context_menu) {509_update_menu_icons();510}511} break;512513case NOTIFICATION_PROCESS: {514// Load icons as a coroutine to speed up launch when you have hundreds of projects.515if (_icon_load_index < _projects.size()) {516Item &item = _projects.write[_icon_load_index];517if (item.control->should_load_project_icon()) {518_load_project_icon(_icon_load_index);519}520_icon_load_index++;521522// Scan directories in thread to avoid blocking the window.523} else if (scan_data && scan_data->scan_in_progress.is_set()) {524// Wait for the thread.525} else {526set_process(false);527if (scan_data) {528_scan_finished();529}530}531} break;532533case NOTIFICATION_ACCESSIBILITY_UPDATE: {534RID ae = get_accessibility_element();535ERR_FAIL_COND(ae.is_null());536537DisplayServer::get_singleton()->accessibility_update_set_role(ae, DisplayServer::AccessibilityRole::ROLE_LIST_BOX);538DisplayServer::get_singleton()->accessibility_update_set_list_item_count(ae, _projects.size());539DisplayServer::get_singleton()->accessibility_update_set_flag(ae, DisplayServer::AccessibilityFlags::FLAG_MULTISELECTABLE, false);540}541}542}543544// Projects scan.545546void ProjectList::_scan_thread(void *p_scan_data) {547ScanData *scan_data = static_cast<ScanData *>(p_scan_data);548549for (const String &base_path : scan_data->paths_to_scan) {550print_verbose(vformat("Scanning for projects in \"%s\".", base_path));551_scan_folder_recursive(base_path, &scan_data->found_projects, scan_data->scan_in_progress);552553if (!scan_data->scan_in_progress.is_set()) {554print_verbose("Scan aborted.");555break;556}557}558print_verbose(vformat("Found %d project(s).", scan_data->found_projects.size()));559scan_data->scan_in_progress.clear();560}561562void ProjectList::_scan_finished() {563if (scan_data->scan_in_progress.is_set()) {564// Abort scanning.565scan_data->scan_in_progress.clear();566}567568scan_data->thread->wait_to_finish();569memdelete(scan_data->thread);570if (scan_progress) {571scan_progress->hide();572}573574for (const String &E : scan_data->found_projects) {575add_project(E, false);576}577memdelete(scan_data);578scan_data = nullptr;579580save_config();581582if (ProjectManager::get_singleton()->is_initialized()) {583update_project_list();584}585}586587// Initialization & loading.588589void ProjectList::_migrate_config() {590// Proposal #1637 moved the project list from editor settings to a separate config file591// If the new config file doesn't exist, populate it from EditorSettings592if (FileAccess::exists(_config_path)) {593return;594}595596List<PropertyInfo> properties;597EditorSettings::get_singleton()->get_property_list(&properties);598599for (const PropertyInfo &E : properties) {600// This is actually something like "projects/C:::Documents::Godot::Projects::MyGame"601String property_key = E.name;602if (!property_key.begins_with("projects/")) {603continue;604}605606String path = EDITOR_GET(property_key);607print_line("Migrating legacy project '" + path + "'.");608609String favoriteKey = "favorite_projects/" + property_key.get_slicec('/', 1);610bool favorite = EditorSettings::get_singleton()->has_setting(favoriteKey);611add_project(path, favorite);612if (favorite) {613EditorSettings::get_singleton()->erase(favoriteKey);614}615EditorSettings::get_singleton()->erase(property_key);616}617618save_config();619}620621void ProjectList::save_config() {622_config.save(_config_path);623}624625// Load project data from p_property_key and return it in a ProjectList::Item.626// p_favorite is passed directly into the Item.627ProjectList::Item ProjectList::load_project_data(const String &p_path, bool p_favorite) {628String conf = p_path.path_join("project.godot");629bool grayed = false;630bool missing = false;631bool recovery_mode = false;632633Ref<ConfigFile> cf = memnew(ConfigFile);634Error cf_err = cf->load(conf);635636int config_version = 0;637String cf_project_name;638String project_name = TTR("Unnamed Project");639if (cf_err == OK) {640cf_project_name = cf->get_value("application", "config/name", "");641if (!cf_project_name.is_empty()) {642project_name = cf_project_name.xml_unescape();643}644config_version = (int)cf->get_value("", "config_version", 0);645}646647if (config_version > ProjectSettings::CONFIG_VERSION) {648// Comes from an incompatible (more recent) Godot version, gray it out.649grayed = true;650}651652const String description = cf->get_value("application", "config/description", "");653const PackedStringArray tags = cf->get_value("application", "config/tags", PackedStringArray());654const String main_scene = cf->get_value("application", "run/main_scene", "");655656String icon = cf->get_value("application", "config/icon", "");657if (icon.begins_with("uid://")) {658Error err;659Ref<FileAccess> file = FileAccess::open(p_path.path_join(".godot/uid_cache.bin"), FileAccess::READ, &err);660if (err == OK) {661icon = ResourceUID::get_path_from_cache(file, icon);662if (icon.is_empty()) {663WARN_PRINT(vformat("Could not load icon from UID for project at path \"%s\". Make sure UID cache exists.", p_path));664}665} else {666// Cache does not exist yet, so ignore and fallback to default icon.667icon = "";668}669}670671PackedStringArray project_features = cf->get_value("application", "config/features", PackedStringArray());672PackedStringArray unsupported_features = ProjectSettings::get_unsupported_features(project_features);673674String project_version = "?";675for (int i = 0; i < project_features.size(); i++) {676if (ProjectList::project_feature_looks_like_version(project_features[i])) {677project_version = project_features[i];678break;679}680}681682if (config_version < ProjectSettings::CONFIG_VERSION) {683// Previous versions may not have unsupported features.684if (config_version == 4) {685unsupported_features.push_back("3.x");686project_version = "3.x";687} else {688unsupported_features.push_back(TTR("Unknown version"));689}690}691692uint64_t last_edited = 0;693if (cf_err == OK) {694// The modification date marks the date the project was last edited.695// This is because the `project.godot` file will always be modified696// when editing a project (but not when running it).697last_edited = FileAccess::get_modified_time(conf);698699String fscache = p_path.path_join(".fscache");700if (FileAccess::exists(fscache)) {701uint64_t cache_modified = FileAccess::get_modified_time(fscache);702if (cache_modified > last_edited) {703last_edited = cache_modified;704}705}706} else {707grayed = true;708missing = true;709}710711for (const String &tag : tags) {712ProjectManager::get_singleton()->add_new_tag(tag);713}714715// We can't use OS::get_user_dir() because it attempts to load paths from the current loaded project through ProjectSettings,716// while here we're parsing project files externally. Therefore, we have to replicate its behavior.717String user_dir;718if (!cf_project_name.is_empty()) {719String appname = OS::get_singleton()->get_safe_dir_name(cf_project_name);720bool use_custom_dir = cf->get_value("application", "config/use_custom_user_dir", false);721if (use_custom_dir) {722String custom_dir = OS::get_singleton()->get_safe_dir_name(cf->get_value("application", "config/custom_user_dir_name", ""), true);723if (custom_dir.is_empty()) {724custom_dir = appname;725}726user_dir = custom_dir;727} else {728user_dir = OS::get_singleton()->get_godot_dir_name().path_join("app_userdata").path_join(appname);729}730} else {731user_dir = OS::get_singleton()->get_godot_dir_name().path_join("app_userdata").path_join("[unnamed project]");732}733734String recovery_mode_lock_file = OS::get_singleton()->get_user_data_dir(user_dir).path_join(".recovery_mode_lock");735recovery_mode = FileAccess::exists(recovery_mode_lock_file);736737return Item(project_name, description, project_version, tags, p_path, icon, main_scene, unsupported_features, last_edited, p_favorite, grayed, missing, recovery_mode, config_version);738}739740void ProjectList::_update_icons_async() {741_icon_load_index = 0;742set_process(true);743}744745void ProjectList::_load_project_icon(int p_index) {746Item &item = _projects.write[p_index];747748Ref<Texture2D> default_icon = get_editor_theme_icon(SNAME("DefaultProjectIcon"));749Ref<Texture2D> icon;750if (!item.icon.is_empty()) {751Ref<Image> img;752img.instantiate();753Error err = img->load(item.icon.replace_first("res://", item.path + "/"));754if (err == OK) {755img->resize(default_icon->get_width(), default_icon->get_height(), Image::INTERPOLATE_LANCZOS);756icon = ImageTexture::create_from_image(img);757}758}759if (icon.is_null()) {760icon = default_icon;761}762763item.control->set_project_icon(icon);764}765766// Project list updates.767768void ProjectList::update_project_list() {769// This is a full, hard reload of the list. Don't call this unless really required, it's expensive.770// If you have 150 projects, it may read through 150 files on your disk at once + load 150 icons.771// FIXME: Does it really have to be a full, hard reload? Runtime updates should be made much cheaper.772773if (ProjectManager::get_singleton()->is_initialized()) {774// Clear whole list775for (int i = 0; i < _projects.size(); ++i) {776Item &project = _projects.write[i];777CRASH_COND(project.control == nullptr);778memdelete(project.control); // Why not queue_free()?779}780781_projects.clear();782_last_clicked = "";783_selected_project_paths.clear();784785load_project_list();786}787788// Create controls789for (int i = 0; i < _projects.size(); ++i) {790_create_project_item_control(i);791}792793sort_projects();794_update_icons_async();795update_dock_menu();796797set_v_scroll(0);798emit_signal(SNAME(SIGNAL_LIST_CHANGED));799queue_accessibility_update();800}801802void ProjectList::sort_projects() {803SortArray<Item, ProjectListComparator> sorter;804sorter.compare.order_option = _order_option;805sorter.sort(_projects.ptrw(), _projects.size());806807String search_term;808PackedStringArray tags;809810if (!_search_term.is_empty()) {811PackedStringArray search_parts = _search_term.split(" ");812if (search_parts.size() > 1 || search_parts[0].begins_with("tag:")) {813PackedStringArray remaining;814for (const String &part : search_parts) {815if (part.begins_with("tag:")) {816tags.push_back(part.get_slicec(':', 1));817} else {818remaining.append(part);819}820}821search_term = String(" ").join(remaining); // Search term without tags.822} else {823search_term = _search_term;824}825}826827for (int i = 0; i < _projects.size(); ++i) {828Item &item = _projects.write[i];829830bool item_visible = true;831if (!_search_term.is_empty()) {832String search_path;833if (search_term.contains_char('/')) {834// Search path will match the whole path835search_path = item.path;836} else {837// Search path will only match the last path component to make searching more strict838search_path = item.path.get_file();839}840841bool missing_tags = false;842for (const String &tag : tags) {843if (!item.tags.has(tag)) {844missing_tags = true;845break;846}847}848849// When searching, display projects whose name or path contain the search term and whose tags match the searched tags.850item_visible = !missing_tags && (search_term.is_empty() || item.project_name.containsn(search_term) || search_path.containsn(search_term));851}852853item.control->set_visible(item_visible);854}855856for (int i = 0; i < _projects.size(); ++i) {857Item &item = _projects.write[i];858item.control->get_parent()->move_child(item.control, i);859}860861// Rewind the coroutine because order of projects changed862_update_icons_async();863update_dock_menu();864queue_accessibility_update();865}866867int ProjectList::get_project_count() const {868return _projects.size();869}870871void ProjectList::find_projects(const String &p_path) {872PackedStringArray paths = { p_path };873find_projects_multiple(paths);874}875876void ProjectList::find_projects_multiple(const PackedStringArray &p_paths) {877if (!scan_progress && is_inside_tree()) {878scan_progress = memnew(AcceptDialog);879scan_progress->set_title(TTRC("Scanning"));880scan_progress->set_ok_button_text(TTRC("Cancel"));881882VBoxContainer *vb = memnew(VBoxContainer);883scan_progress->add_child(vb);884885Label *label = memnew(Label);886label->set_text(TTRC("Scanning for projects..."));887vb->add_child(label);888889ProgressBar *progress = memnew(ProgressBar);890progress->set_indeterminate(true);891vb->add_child(progress);892893add_child(scan_progress);894scan_progress->connect(SceneStringName(confirmed), callable_mp(this, &ProjectList::_scan_finished));895scan_progress->connect("canceled", callable_mp(this, &ProjectList::_scan_finished));896}897898scan_data = memnew(ScanData);899scan_data->paths_to_scan = p_paths;900scan_data->scan_in_progress.set();901902scan_data->thread = memnew(Thread);903scan_data->thread->start(_scan_thread, scan_data);904905if (scan_progress) {906scan_progress->reset_size();907scan_progress->popup_centered();908}909set_process(true);910}911912void ProjectList::load_project_list() {913_config.load(_config_path);914Vector<String> sections = _config.get_sections();915916for (const String &path : sections) {917bool favorite = _config.get_value(path, "favorite", false);918_projects.push_back(load_project_data(path, favorite));919}920}921922void ProjectList::_scan_folder_recursive(const String &p_path, List<String> *r_projects, const SafeFlag &p_scan_active) {923if (!p_scan_active.is_set()) {924return;925}926927Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);928Error error = da->change_dir(p_path);929ERR_FAIL_COND_MSG(error != OK, vformat("Failed to open the path \"%s\" for scanning (code %d).", p_path, error));930931da->list_dir_begin();932String n = da->get_next();933while (!n.is_empty()) {934if (!p_scan_active.is_set()) {935return;936}937938if (da->current_is_dir() && n[0] != '.') {939_scan_folder_recursive(da->get_current_dir().path_join(n), r_projects, p_scan_active);940} else if (n == "project.godot") {941r_projects->push_back(da->get_current_dir());942}943n = da->get_next();944}945da->list_dir_end();946}947948// Project list items.949950void ProjectList::add_project(const String &dir_path, bool favorite) {951if (!_config.has_section(dir_path)) {952_config.set_value(dir_path, "favorite", favorite);953}954queue_accessibility_update();955}956957void ProjectList::set_project_version(const String &p_project_path, int p_version) {958for (ProjectList::Item &E : _projects) {959if (E.path == p_project_path) {960E.version = p_version;961break;962}963}964}965966int ProjectList::refresh_project(const String &dir_path) {967// Reloads information about a specific project.968// If it wasn't loaded and should be in the list, it is added (i.e new project).969// If it isn't in the list anymore, it is removed.970// If it is in the list but doesn't exist anymore, it is marked as missing.971972bool should_be_in_list = _config.has_section(dir_path);973bool is_favorite = _config.get_value(dir_path, "favorite", false);974975bool was_selected = _selected_project_paths.has(dir_path);976977// Remove item in any case978for (int i = 0; i < _projects.size(); ++i) {979const Item &existing_item = _projects[i];980if (existing_item.path == dir_path) {981_remove_project(i, false);982break;983}984}985986int index = -1;987if (should_be_in_list) {988// Recreate it with updated info989990Item item = load_project_data(dir_path, is_favorite);991992_projects.push_back(item);993_create_project_item_control(_projects.size() - 1);994995sort_projects();996997for (int i = 0; i < _projects.size(); ++i) {998if (_projects[i].path == dir_path) {999if (was_selected) {1000ensure_project_visible(i);1001}1002_load_project_icon(i);10031004index = i;1005break;1006}1007}1008}10091010return index;1011}10121013int ProjectList::get_index(const ProjectListItemControl *p_control) const {1014for (int i = 0; i < _projects.size(); ++i) {1015if (_projects[i].control == p_control) {1016return i;1017}1018}1019return -1;1020}10211022void ProjectList::ensure_project_visible(int p_index) {1023const Item &item = _projects[p_index];1024// Since follow focus is enabled.1025item.control->grab_focus(true);1026}10271028void ProjectList::_create_project_item_control(int p_index) {1029// Will be added last in the list, so make sure indexes match1030ERR_FAIL_COND(p_index != project_list_vbox->get_child_count());10311032Item &item = _projects.write[p_index];1033ERR_FAIL_COND(item.control != nullptr); // Already created10341035ProjectListItemControl *hb = memnew(ProjectListItemControl);1036hb->add_theme_constant_override("separation", 10 * EDSCALE);10371038hb->set_project_title(!item.missing ? item.project_name : TTR("Missing Project"));1039hb->set_project_path(item.path);1040hb->set_tooltip_text(item.description);1041hb->set_tags(item.tags, this);1042hb->set_unsupported_features(item.unsupported_features.duplicate());1043hb->set_project_version(item.project_version);1044hb->set_last_edited_info(item.get_last_edited_string());10451046hb->set_is_favorite(item.favorite);1047hb->set_is_missing(item.missing);1048hb->set_is_grayed(item.grayed);10491050hb->connect(SceneStringName(gui_input), callable_mp(this, &ProjectList::_list_item_input).bind(hb));1051hb->connect("favorite_pressed", callable_mp(this, &ProjectList::_on_favorite_pressed).bind(hb));10521053#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)1054hb->connect("explore_pressed", callable_mp(this, &ProjectList::_on_explore_pressed).bind(item.path));1055#endif1056hb->connect("request_menu", callable_mp(this, &ProjectList::_open_menu).bind(hb));10571058project_list_vbox->add_child(hb);1059item.control = hb;1060}10611062void ProjectList::_toggle_project(int p_index) {1063// This methods adds to the selection or removes from the1064// selection.1065Item &item = _projects.write[p_index];10661067if (_selected_project_paths.has(item.path)) {1068_deselect_project_nocheck(p_index);1069} else {1070_select_project_nocheck(p_index);1071}1072}10731074void ProjectList::_remove_project(int p_index, bool p_update_config) {1075const Item item = _projects[p_index]; // Take a copy10761077_selected_project_paths.erase(item.path);10781079if (_last_clicked == item.path) {1080_last_clicked = "";1081}10821083memdelete(item.control);1084_projects.remove_at(p_index);10851086if (p_update_config) {1087_config.erase_section(item.path);1088// Not actually saving the file, in case you are doing more changes to settings1089}10901091queue_accessibility_update();1092update_dock_menu();1093}10941095void ProjectList::_list_item_input(const Ref<InputEvent> &p_ev, Control *p_hb) {1096Ref<InputEventMouseButton> mb = p_ev;1097int clicked_index = p_hb->get_index();1098const Item &clicked_project = _projects[clicked_index];10991100if (mb.is_valid() && mb->is_pressed()) {1101if (mb->get_button_index() == MouseButton::LEFT) {1102if (mb->is_shift_pressed() && _selected_project_paths.size() > 0 && !_last_clicked.is_empty() && clicked_project.path != _last_clicked) {1103int anchor_index = -1;1104for (int i = 0; i < _projects.size(); ++i) {1105const Item &p = _projects[i];1106if (p.path == _last_clicked) {1107anchor_index = p.control->get_index();1108break;1109}1110}1111CRASH_COND(anchor_index == -1);1112_select_project_range(anchor_index, clicked_index);11131114} else if (mb->is_command_or_control_pressed()) {1115_toggle_project(clicked_index);11161117} else {1118_last_clicked = clicked_project.path;1119select_project(clicked_index, true);1120}11211122emit_signal(SNAME(SIGNAL_SELECTION_CHANGED));11231124// Do not allow opening a project more than once using a single project manager instance.1125// Opening the same project in several editor instances at once can lead to various issues.1126if (!mb->is_command_or_control_pressed() && mb->is_double_click() && !project_opening_initiated) {1127emit_signal(SNAME(SIGNAL_PROJECT_ASK_OPEN));1128}1129} else if (mb->get_button_index() == MouseButton::RIGHT) {1130_open_menu(mb->get_position(), p_hb);1131}1132}11331134Ref<InputEventKey> kev = p_ev;11351136if (kev.is_valid() && kev->is_pressed()) {1137switch (kev->get_keycode()) {1138case Key::E: {1139_on_explore_pressed(clicked_project.path);1140accept_event();1141} break;1142case Key::F: {1143if (kev->is_command_or_control_pressed()) {1144return; // Focus the search box by the ProjectManager.1145}1146_on_favorite_pressed(p_hb);1147accept_event();1148} break;1149default: {1150} break;1151}1152}1153}11541155void ProjectList::_on_favorite_pressed(Node *p_hb) {1156ProjectListItemControl *control = Object::cast_to<ProjectListItemControl>(p_hb);11571158int index = control->get_index();1159Item item = _projects.write[index]; // Take copy11601161item.favorite = !item.favorite;11621163_config.set_value(item.path, "favorite", item.favorite);1164save_config();11651166_projects.write[index] = item;11671168control->set_is_favorite(item.favorite);11691170sort_projects();11711172// As controls are sorted, the calls are delayed in case follow focus does not take effect.1173if (Input::get_singleton()->is_key_pressed(Key::ALT)) {1174callable_mp((ScrollContainer *)this, &ScrollContainer::ensure_control_visible).call_deferred(control);1175} else {1176// Do not follow the control when toggling.1177callable_mp(this, &ProjectList::ensure_project_visible).call_deferred(index);1178}11791180update_dock_menu();1181}11821183void ProjectList::_on_explore_pressed(const String &p_path) {1184OS::get_singleton()->shell_show_in_file_manager(p_path, true);1185}11861187void ProjectList::_open_menu(const Vector2 &p_at, Control *p_hb) {1188int clicked_index = p_hb->get_index();1189const Item &clicked_project = _projects[clicked_index];11901191if (!project_context_menu) {1192project_context_menu = memnew(PopupMenu);1193project_context_menu->add_item(TTRC("Open in Editor"), MENU_EDIT);1194project_context_menu->add_item(TTRC("Open in Editor (Verbose Mode)"), MENU_EDIT_VERBOSE);1195project_context_menu->add_item(TTRC("Open in Editor (Recovery Mode)"), MENU_EDIT_RECOVERY);1196project_context_menu->add_item(TTRC("Run Project"), MENU_RUN);1197project_context_menu->add_separator();1198#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)1199project_context_menu->add_item(TTRC("Show in File Manager"), MENU_SHOW_IN_FILE_MANAGER);1200#endif1201project_context_menu->add_item(TTRC("Copy Path"), MENU_COPY_PATH);1202project_context_menu->add_separator();1203project_context_menu->add_item(TTRC("Rename"), MENU_RENAME);1204project_context_menu->add_item(TTRC("Manage Tags"), MENU_MANAGE_TAGS);1205project_context_menu->add_item(TTRC("Duplicate"), MENU_DUPLICATE);1206project_context_menu->add_item(TTRC("Remove from Project List"), MENU_REMOVE);1207add_child(project_context_menu);1208project_context_menu->connect(SceneStringName(id_pressed), callable_mp(this, &ProjectList::_menu_option));1209_update_menu_icons();1210}1211clicked_project.control->grab_focus(true);12121213for (int id : Vector<int>{1214MENU_EDIT,1215MENU_EDIT_VERBOSE,1216MENU_EDIT_RECOVERY,1217MENU_RUN,1218#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)1219MENU_SHOW_IN_FILE_MANAGER,1220#endif1221MENU_RENAME,1222MENU_MANAGE_TAGS,1223MENU_DUPLICATE }) {1224project_context_menu->set_item_disabled(project_context_menu->get_item_index(id), clicked_project.missing);1225}12261227project_context_menu->set_position(p_hb->get_screen_position() + p_at);1228project_context_menu->reset_size();1229project_context_menu->popup();1230}12311232void ProjectList::_menu_option(int p_option) {1233emit_signal(SIGNAL_MENU_OPTION_SELECTED, p_option);1234}12351236void ProjectList::_update_menu_icons() {1237project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_EDIT), get_editor_theme_icon("Edit"));1238project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_EDIT_VERBOSE), get_editor_theme_icon("Notification"));1239project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_EDIT_RECOVERY), get_editor_theme_icon("NodeWarning"));1240project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_RUN), get_editor_theme_icon("Play"));1241#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)1242project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_SHOW_IN_FILE_MANAGER), get_editor_theme_icon("Load"));1243#endif1244project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_COPY_PATH), get_editor_theme_icon("ActionCopy"));1245project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_RENAME), get_editor_theme_icon("Rename"));1246project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_MANAGE_TAGS), get_editor_theme_icon("Script"));1247project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_DUPLICATE), get_editor_theme_icon("Duplicate"));1248project_context_menu->set_item_icon(project_context_menu->get_item_index(MENU_REMOVE), get_editor_theme_icon("Remove"));1249}12501251// Project list selection.12521253void ProjectList::_clear_project_selection() {1254Vector<Item> previous_selected_items = get_selected_projects();1255_selected_project_paths.clear();12561257for (int i = 0; i < previous_selected_items.size(); ++i) {1258previous_selected_items[i].control->set_selected(false);1259}1260queue_accessibility_update();1261}12621263void ProjectList::_select_project_nocheck(int p_index, bool p_hide_focus) {1264Item &item = _projects.write[p_index];1265_selected_project_paths.insert(item.path);1266item.control->set_selected(true, p_hide_focus);1267queue_accessibility_update();1268}12691270void ProjectList::_deselect_project_nocheck(int p_index) {1271Item &item = _projects.write[p_index];1272_selected_project_paths.erase(item.path);1273item.control->set_selected(false);1274queue_accessibility_update();1275}12761277inline void _sort_project_range(int &a, int &b) {1278if (a > b) {1279int temp = a;1280a = b;1281b = temp;1282}1283}12841285void ProjectList::_select_project_range(int p_begin, int p_end) {1286_clear_project_selection();12871288_sort_project_range(p_begin, p_end);1289for (int i = p_begin; i <= p_end; ++i) {1290_select_project_nocheck(i);1291}1292}12931294void ProjectList::select_project(int p_index, bool p_hide_focus) {1295// This method keeps only one project selected.1296_clear_project_selection();1297_select_project_nocheck(p_index, p_hide_focus);1298}12991300void ProjectList::deselect_project(int p_index) {1301_deselect_project_nocheck(p_index);1302}13031304void ProjectList::select_first_visible_project() {1305_clear_project_selection();13061307for (int i = 0; i < _projects.size(); i++) {1308if (_projects[i].control->is_visible()) {1309_select_project_nocheck(i);1310break;1311}1312}1313}13141315void ProjectList::deselect_all_visible_projects() {1316for (int i = 0; i < _projects.size(); i++) {1317if (_projects[i].control->is_visible()) {1318_deselect_project_nocheck(i);1319}1320}1321}13221323void ProjectList::select_all_visible_projects() {1324for (int i = 0; i < _projects.size(); i++) {1325if (_projects[i].control->is_visible()) {1326_select_project_nocheck(i);1327}1328}1329}13301331Vector<ProjectList::Item> ProjectList::get_selected_projects() const {1332Vector<Item> items;1333if (_selected_project_paths.is_empty()) {1334return items;1335}1336items.resize(_selected_project_paths.size());1337int j = 0;1338for (int i = 0; i < _projects.size(); ++i) {1339const Item &item = _projects[i];1340if (_selected_project_paths.has(item.path)) {1341items.write[j++] = item;1342}1343}1344ERR_FAIL_COND_V(j != items.size(), items);1345return items;1346}13471348const HashSet<String> &ProjectList::get_selected_project_keys() const {1349// Faster if that's all you need1350return _selected_project_paths;1351}13521353int ProjectList::get_single_selected_index() const {1354if (_selected_project_paths.is_empty()) {1355// Default selection1356return 0;1357}1358String key;1359if (_selected_project_paths.size() == 1) {1360// Only one selected1361key = *_selected_project_paths.begin();1362} else {1363// Multiple selected, consider the last clicked one as "main"1364key = _last_clicked;1365}1366for (int i = 0; i < _projects.size(); ++i) {1367if (_projects[i].path == key) {1368return i;1369}1370}1371return 0;1372}13731374void ProjectList::erase_selected_projects(bool p_delete_project_contents) {1375if (_selected_project_paths.is_empty()) {1376return;1377}13781379for (int i = 0; i < _projects.size(); ++i) {1380Item &item = _projects.write[i];1381if (_selected_project_paths.has(item.path) && item.control->is_visible()) {1382_config.erase_section(item.path);13831384// Comment out for now until we have a better warning system to1385// ensure users delete their project only.1386//if (p_delete_project_contents) {1387// OS::get_singleton()->move_to_trash(item.path);1388//}13891390memdelete(item.control);1391_projects.remove_at(i);1392--i;1393}1394}13951396save_config();1397_selected_project_paths.clear();1398_last_clicked = "";13991400update_dock_menu();1401}14021403// Missing projects.14041405bool ProjectList::is_any_project_missing() const {1406for (int i = 0; i < _projects.size(); ++i) {1407if (_projects[i].missing) {1408return true;1409}1410}1411return false;1412}14131414void ProjectList::erase_missing_projects() {1415if (_projects.is_empty()) {1416return;1417}14181419int deleted_count = 0;1420int remaining_count = 0;14211422for (int i = 0; i < _projects.size(); ++i) {1423const Item &item = _projects[i];14241425if (item.missing) {1426_remove_project(i, true);1427--i;1428++deleted_count;14291430} else {1431++remaining_count;1432}1433}14341435print_line("Removed " + itos(deleted_count) + " projects from the list, remaining " + itos(remaining_count) + " projects");1436save_config();1437}14381439// Project list sorting and filtering.14401441void ProjectList::set_search_term(String p_search_term) {1442_search_term = p_search_term;1443}14441445void ProjectList::add_search_tag(const String &p_tag) {1446const String tag_string = "tag:" + p_tag;14471448int exists = _search_term.find(tag_string);1449if (exists > -1) {1450_search_term = _search_term.erase(exists, tag_string.length() + 1);1451} else if (_search_term.is_empty() || _search_term.ends_with(" ")) {1452_search_term += tag_string;1453} else {1454_search_term += " " + tag_string;1455}1456ProjectManager::get_singleton()->get_search_box()->set_text(_search_term);14571458sort_projects();1459}14601461void ProjectList::set_order_option(int p_option, bool p_save) {1462FilterOption selected = (FilterOption)p_option;1463if (p_save) {1464EditorSettings::get_singleton()->set("project_manager/sorting_order", p_option);1465EditorSettings::get_singleton()->save();1466}1467_order_option = selected;14681469sort_projects();1470}14711472// Global menu integration.14731474void ProjectList::update_dock_menu() {1475if (!NativeMenu::get_singleton()->has_feature(NativeMenu::FEATURE_GLOBAL_MENU)) {1476return;1477}1478RID dock_rid = NativeMenu::get_singleton()->get_system_menu(NativeMenu::DOCK_MENU_ID);1479NativeMenu::get_singleton()->clear(dock_rid);14801481int favs_added = 0;1482int total_added = 0;1483for (int i = 0; i < _projects.size(); ++i) {1484if (!_projects[i].grayed && !_projects[i].missing) {1485if (_projects[i].favorite) {1486favs_added++;1487} else {1488if (favs_added != 0) {1489NativeMenu::get_singleton()->add_separator(dock_rid);1490}1491favs_added = 0;1492}1493NativeMenu::get_singleton()->add_item(dock_rid, _projects[i].project_name + " ( " + _projects[i].path + " )", callable_mp(this, &ProjectList::_global_menu_open_project), Callable(), i);1494total_added++;1495}1496}1497if (total_added != 0) {1498NativeMenu::get_singleton()->add_separator(dock_rid);1499}1500NativeMenu::get_singleton()->add_item(dock_rid, TTR("New Window"), callable_mp(this, &ProjectList::_global_menu_new_window));1501}15021503void ProjectList::_global_menu_new_window(const Variant &p_tag) {1504List<String> args;1505args.push_back("-p");1506OS::get_singleton()->create_instance(args);1507}15081509void ProjectList::_global_menu_open_project(const Variant &p_tag) {1510int idx = (int)p_tag;15111512if (idx >= 0 && idx < _projects.size()) {1513String conf = _projects[idx].path.path_join("project.godot");1514List<String> args;1515args.push_back(conf);1516OS::get_singleton()->create_instance(args);1517}1518}15191520// Object methods.15211522void ProjectList::_bind_methods() {1523ADD_SIGNAL(MethodInfo(SIGNAL_LIST_CHANGED));1524ADD_SIGNAL(MethodInfo(SIGNAL_SELECTION_CHANGED));1525ADD_SIGNAL(MethodInfo(SIGNAL_PROJECT_ASK_OPEN));1526ADD_SIGNAL(MethodInfo(SIGNAL_MENU_OPTION_SELECTED));1527}15281529ProjectList::ProjectList() {1530set_follow_focus(true);15311532project_list_vbox = memnew(VBoxContainer);1533project_list_vbox->set_h_size_flags(Control::SIZE_EXPAND_FILL);1534add_child(project_list_vbox);15351536_config_path = EditorPaths::get_singleton()->get_data_dir().path_join("projects.cfg");1537_migrate_config();1538}153915401541