Path: blob/master/editor/project_manager/project_list.cpp
9896 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/io/dir_access.h"34#include "core/os/time.h"35#include "core/version.h"36#include "editor/editor_string_names.h"37#include "editor/file_system/editor_paths.h"38#include "editor/project_manager/project_manager.h"39#include "editor/project_manager/project_tag.h"40#include "editor/settings/editor_settings.h"41#include "editor/themes/editor_scale.h"42#include "scene/gui/button.h"43#include "scene/gui/dialogs.h"44#include "scene/gui/label.h"45#include "scene/gui/line_edit.h"46#include "scene/gui/progress_bar.h"47#include "scene/gui/texture_button.h"48#include "scene/gui/texture_rect.h"49#include "scene/resources/image_texture.h"5051const char *ProjectList::SIGNAL_LIST_CHANGED = "list_changed";52const char *ProjectList::SIGNAL_SELECTION_CHANGED = "selection_changed";53const char *ProjectList::SIGNAL_PROJECT_ASK_OPEN = "project_ask_open";5455void ProjectListItemControl::_notification(int p_what) {56switch (p_what) {57case NOTIFICATION_THEME_CHANGED: {58if (icon_needs_reload) {59// The project icon may not be loaded by the time the control is displayed,60// so use a loading placeholder.61project_icon->set_texture(get_editor_theme_icon(SNAME("ProjectIconLoading")));62}6364project_title->begin_bulk_theme_override();65project_title->add_theme_font_override(SceneStringName(font), get_theme_font(SNAME("title"), EditorStringName(EditorFonts)));66project_title->add_theme_font_size_override(SceneStringName(font_size), get_theme_font_size(SNAME("title_size"), EditorStringName(EditorFonts)));67project_title->add_theme_color_override(SceneStringName(font_color), get_theme_color(SceneStringName(font_color), SNAME("Tree")));68project_title->end_bulk_theme_override();6970project_path->add_theme_color_override(SceneStringName(font_color), get_theme_color(SceneStringName(font_color), SNAME("Tree")));71project_unsupported_features->set_texture(get_editor_theme_icon(SNAME("NodeWarning")));7273favorite_button->set_texture_normal(get_editor_theme_icon(SNAME("Favorites")));7475if (project_is_missing) {76explore_button->set_button_icon(get_editor_theme_icon(SNAME("FileBroken")));77#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)78} else {79explore_button->set_button_icon(get_editor_theme_icon(SNAME("Load")));80#endif81}82} break;8384case NOTIFICATION_MOUSE_ENTER: {85is_hovering = true;86queue_redraw();87queue_accessibility_update();88} break;8990case NOTIFICATION_MOUSE_EXIT: {91is_hovering = false;92queue_redraw();93queue_accessibility_update();94} break;9596case NOTIFICATION_ACCESSIBILITY_UPDATE: {97RID ae = get_accessibility_element();98ERR_FAIL_COND(ae.is_null());99100DisplayServer::get_singleton()->accessibility_update_set_role(ae, DisplayServer::AccessibilityRole::ROLE_LIST_BOX_OPTION);101DisplayServer::get_singleton()->accessibility_update_set_name(ae, TTR("Project") + " " + project_title->get_text());102DisplayServer::get_singleton()->accessibility_update_set_value(ae, project_title->get_text());103104DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_CLICK, callable_mp(this, &ProjectListItemControl::_accessibility_action_open));105DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_SCROLL_INTO_VIEW, callable_mp(this, &ProjectListItemControl::_accessibility_action_scroll_into_view));106DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_FOCUS, callable_mp(this, &ProjectListItemControl::_accessibility_action_focus));107DisplayServer::get_singleton()->accessibility_update_add_action(ae, DisplayServer::AccessibilityAction::ACTION_BLUR, callable_mp(this, &ProjectListItemControl::_accessibility_action_blur));108109ProjectList *pl = get_list();110if (pl) {111DisplayServer::get_singleton()->accessibility_update_set_list_item_index(ae, pl->get_index(this));112}113DisplayServer::get_singleton()->accessibility_update_set_list_item_level(ae, 0);114DisplayServer::get_singleton()->accessibility_update_set_list_item_selected(ae, is_selected);115} break;116117case NOTIFICATION_FOCUS_ENTER: {118ProjectList *pl = get_list();119if (pl) {120int idx = pl->get_index(this);121if (idx >= 0) {122pl->ensure_project_visible(idx);123pl->select_project(idx);124125pl->emit_signal(SNAME(ProjectList::SIGNAL_SELECTION_CHANGED));126}127}128} break;129130case NOTIFICATION_DRAW: {131if (is_selected) {132draw_style_box(get_theme_stylebox(SNAME("selected"), SNAME("Tree")), Rect2(Point2(), get_size()));133}134if (is_hovering) {135draw_style_box(get_theme_stylebox(SNAME("hovered"), SNAME("Tree")), Rect2(Point2(), get_size()));136}137138draw_line(Point2(0, get_size().y + 1), Point2(get_size().x, get_size().y + 1), get_theme_color(SNAME("guide_color"), SNAME("Tree")));139} break;140}141}142143ProjectList *ProjectListItemControl::get_list() const {144if (!is_inside_tree()) {145return nullptr;146}147ProjectList *pl = Object::cast_to<ProjectList>(get_parent()->get_parent());148return pl;149}150151void ProjectListItemControl::_accessibility_action_scroll_into_view(const Variant &p_data) {152ProjectList *pl = get_list();153if (pl) {154int idx = pl->get_index(this);155if (idx >= 0) {156pl->ensure_project_visible(idx);157}158}159}160161void ProjectListItemControl::_accessibility_action_open(const Variant &p_data) {162ProjectList *pl = get_list();163if (pl && !pl->project_opening_initiated) {164pl->emit_signal(SNAME(ProjectList::SIGNAL_PROJECT_ASK_OPEN));165}166}167168void ProjectListItemControl::_accessibility_action_focus(const Variant &p_data) {169ProjectList *pl = get_list();170if (pl) {171int idx = pl->get_index(this);172if (idx >= 0) {173pl->ensure_project_visible(idx);174pl->select_project(idx);175}176}177}178179void ProjectListItemControl::_accessibility_action_blur(const Variant &p_data) {180ProjectList *pl = get_list();181if (pl) {182int idx = pl->get_index(this);183if (idx >= 0) {184pl->ensure_project_visible(idx);185pl->deselect_project(idx);186}187}188}189190void ProjectListItemControl::_favorite_button_pressed() {191emit_signal(SNAME("favorite_pressed"));192}193194void ProjectListItemControl::_explore_button_pressed() {195emit_signal(SNAME("explore_pressed"));196}197198void ProjectListItemControl::set_project_title(const String &p_title) {199project_title->set_text(p_title);200project_title->set_accessibility_name(TTRC("Project Name"));201queue_accessibility_update();202}203204void ProjectListItemControl::set_project_path(const String &p_path) {205project_path->set_text(p_path);206project_path->set_accessibility_name(TTRC("Project Path"));207queue_accessibility_update();208}209210void ProjectListItemControl::set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list) {211for (const String &tag : p_tags) {212ProjectTag *tag_control = memnew(ProjectTag(tag));213tag_container->add_child(tag_control);214tag_control->connect_button_to(callable_mp(p_parent_list, &ProjectList::add_search_tag).bind(tag));215}216}217218void ProjectListItemControl::set_project_icon(const Ref<Texture2D> &p_icon) {219icon_needs_reload = false;220221// The default project icon is 128×128 to look crisp on hiDPI displays,222// but we want the actual displayed size to be 64×64 on loDPI displays.223project_icon->set_expand_mode(TextureRect::EXPAND_IGNORE_SIZE);224project_icon->set_custom_minimum_size(Size2(64, 64) * EDSCALE);225project_icon->set_stretch_mode(TextureRect::STRETCH_KEEP_ASPECT_CENTERED);226227project_icon->set_texture(p_icon);228}229230void ProjectListItemControl::set_last_edited_info(const String &p_info) {231last_edited_info->set_text(p_info);232}233234void ProjectListItemControl::set_project_version(const String &p_info) {235project_version->set_text(p_info);236}237238void ProjectListItemControl::set_unsupported_features(PackedStringArray p_features) {239if (p_features.size() > 0) {240String tooltip_text = "";241for (int i = 0; i < p_features.size(); i++) {242if (ProjectList::project_feature_looks_like_version(p_features[i])) {243PackedStringArray project_version_split = p_features[i].split(".");244int project_version_major = 0, project_version_minor = 0;245if (project_version_split.size() >= 2) {246project_version_major = project_version_split[0].to_int();247project_version_minor = project_version_split[1].to_int();248}249if (GODOT_VERSION_MAJOR != project_version_major || GODOT_VERSION_MINOR <= project_version_minor) {250// Don't show a warning if the project was last edited in a previous minor version.251tooltip_text += TTR("This project was last edited in a different Godot version: ") + p_features[i] + "\n";252}253p_features.remove_at(i);254i--;255}256}257if (p_features.size() > 0) {258String unsupported_features_str = String(", ").join(p_features);259tooltip_text += TTR("This project uses features unsupported by the current build:") + "\n" + unsupported_features_str;260}261if (tooltip_text.is_empty()) {262return;263}264project_version->set_tooltip_text(tooltip_text);265project_unsupported_features->set_focus_mode(FOCUS_ACCESSIBILITY);266project_unsupported_features->set_tooltip_text(tooltip_text);267project_unsupported_features->show();268} else {269project_unsupported_features->hide();270}271}272273bool ProjectListItemControl::should_load_project_icon() const {274return icon_needs_reload;275}276277void ProjectListItemControl::set_selected(bool p_selected) {278is_selected = p_selected;279queue_redraw();280queue_accessibility_update();281}282283void ProjectListItemControl::set_is_favorite(bool p_favorite) {284favorite_button->set_modulate(p_favorite ? Color(1, 1, 1, 1) : Color(1, 1, 1, 0.2));285}286287void ProjectListItemControl::set_is_missing(bool p_missing) {288project_is_missing = p_missing;289290if (project_is_missing) {291project_icon->set_modulate(Color(1, 1, 1, 0.5));292293explore_button->set_button_icon(get_editor_theme_icon(SNAME("FileBroken")));294explore_button->set_tooltip_text(TTRC("Error: Project is missing on the filesystem."));295} else {296#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)297explore_button->set_button_icon(get_editor_theme_icon(SNAME("Load")));298explore_button->set_tooltip_text(TTRC("Show in File Manager"));299#else300// Opening the system file manager is not supported on the Android and web editors.301explore_button->hide();302#endif303}304}305306void ProjectListItemControl::set_is_grayed(bool p_grayed) {307if (p_grayed) {308main_vbox->set_modulate(Color(1, 1, 1, 0.5));309// Don't make the icon less prominent if the parent is already grayed out.310explore_button->set_modulate(Color(1, 1, 1, 1.0));311} else {312main_vbox->set_modulate(Color(1, 1, 1, 1.0));313explore_button->set_modulate(Color(1, 1, 1, 0.5));314}315}316317void ProjectListItemControl::_bind_methods() {318ADD_SIGNAL(MethodInfo("favorite_pressed"));319ADD_SIGNAL(MethodInfo("explore_pressed"));320}321322ProjectListItemControl::ProjectListItemControl() {323set_focus_mode(FocusMode::FOCUS_ALL);324set_auto_translate_mode(AUTO_TRANSLATE_MODE_DISABLED);325326VBoxContainer *favorite_box = memnew(VBoxContainer);327favorite_box->set_alignment(BoxContainer::ALIGNMENT_CENTER);328add_child(favorite_box);329330favorite_button = memnew(TextureButton);331favorite_button->set_name("FavoriteButton");332favorite_button->set_tooltip_text(TTRC("Add to favorites"));333favorite_button->set_auto_translate_mode(AUTO_TRANSLATE_MODE_ALWAYS);334// This makes the project's "hover" style display correctly when hovering the favorite icon.335favorite_button->set_mouse_filter(MOUSE_FILTER_PASS);336favorite_box->add_child(favorite_button);337favorite_button->connect(SceneStringName(pressed), callable_mp(this, &ProjectListItemControl::_favorite_button_pressed));338339project_icon = memnew(TextureRect);340project_icon->set_name("ProjectIcon");341project_icon->set_v_size_flags(SIZE_SHRINK_CENTER);342add_child(project_icon);343344main_vbox = memnew(VBoxContainer);345main_vbox->set_h_size_flags(Control::SIZE_EXPAND_FILL);346add_child(main_vbox);347348Control *ec = memnew(Control);349ec->set_custom_minimum_size(Size2(0, 1));350ec->set_mouse_filter(MOUSE_FILTER_PASS);351main_vbox->add_child(ec);352353// Top half, title, tags and unsupported features labels.354{355HBoxContainer *title_hb = memnew(HBoxContainer);356main_vbox->add_child(title_hb);357358project_title = memnew(Label);359project_title->set_focus_mode(FOCUS_ACCESSIBILITY);360project_title->set_name("ProjectName");361project_title->set_h_size_flags(Control::SIZE_EXPAND_FILL);362project_title->set_clip_text(true);363title_hb->add_child(project_title);364365tag_container = memnew(HBoxContainer);366title_hb->add_child(tag_container);367368Control *spacer = memnew(Control);369spacer->set_custom_minimum_size(Size2(10, 10));370title_hb->add_child(spacer);371}372373// Bottom half, containing the path and view folder button.374{375HBoxContainer *path_hb = memnew(HBoxContainer);376path_hb->set_h_size_flags(Control::SIZE_EXPAND_FILL);377main_vbox->add_child(path_hb);378379explore_button = memnew(Button);380explore_button->set_name("ExploreButton");381explore_button->set_tooltip_auto_translate_mode(AUTO_TRANSLATE_MODE_ALWAYS);382explore_button->set_tooltip_text(TTRC("Open in file manager"));383explore_button->set_flat(true);384path_hb->add_child(explore_button);385explore_button->connect(SceneStringName(pressed), callable_mp(this, &ProjectListItemControl::_explore_button_pressed));386387project_path = memnew(Label);388project_path->set_name("ProjectPath");389project_path->set_focus_mode(FOCUS_ACCESSIBILITY);390project_path->set_structured_text_bidi_override(TextServer::STRUCTURED_TEXT_FILE);391project_path->set_clip_text(true);392project_path->set_h_size_flags(Control::SIZE_EXPAND_FILL);393project_path->set_modulate(Color(1, 1, 1, 0.5));394path_hb->add_child(project_path);395396project_unsupported_features = memnew(TextureRect);397project_unsupported_features->set_name("ProjectUnsupportedFeatures");398project_unsupported_features->set_stretch_mode(TextureRect::STRETCH_KEEP_CENTERED);399path_hb->add_child(project_unsupported_features);400project_unsupported_features->hide();401402project_version = memnew(Label);403project_version->set_focus_mode(FOCUS_ACCESSIBILITY);404project_version->set_name("ProjectVersion");405project_version->set_mouse_filter(Control::MOUSE_FILTER_PASS);406path_hb->add_child(project_version);407408last_edited_info = memnew(Label);409last_edited_info->set_focus_mode(FOCUS_ACCESSIBILITY);410last_edited_info->set_name("LastEditedInfo");411last_edited_info->set_mouse_filter(Control::MOUSE_FILTER_PASS);412last_edited_info->set_tooltip_auto_translate_mode(AUTO_TRANSLATE_MODE_ALWAYS);413last_edited_info->set_tooltip_text(TTRC("Last edited timestamp"));414last_edited_info->set_modulate(Color(1, 1, 1, 0.5));415path_hb->add_child(last_edited_info);416417Control *spacer = memnew(Control);418spacer->set_custom_minimum_size(Size2(10, 10));419path_hb->add_child(spacer);420}421}422423struct ProjectListComparator {424ProjectList::FilterOption order_option = ProjectList::FilterOption::EDIT_DATE;425426// operator<427_FORCE_INLINE_ bool operator()(const ProjectList::Item &a, const ProjectList::Item &b) const {428if (a.favorite && !b.favorite) {429return true;430}431if (b.favorite && !a.favorite) {432return false;433}434switch (order_option) {435case ProjectList::PATH:436return a.path < b.path;437case ProjectList::EDIT_DATE:438return a.last_edited > b.last_edited;439case ProjectList::TAGS:440return a.tag_sort_string < b.tag_sort_string;441default:442return a.project_name < b.project_name;443}444}445};446447// Helpers.448449bool ProjectList::project_feature_looks_like_version(const String &p_feature) {450return p_feature.contains_char('.') && p_feature.substr(0, 3).is_numeric();451}452453// Notifications.454455void ProjectList::_notification(int p_what) {456switch (p_what) {457case NOTIFICATION_TRANSLATION_CHANGED: {458if (is_ready()) {459// FIXME: Technically this only needs to update some dynamic texts, not the whole list.460update_project_list();461}462} break;463464case NOTIFICATION_PROCESS: {465// Load icons as a coroutine to speed up launch when you have hundreds of projects.466if (_icon_load_index < _projects.size()) {467Item &item = _projects.write[_icon_load_index];468if (item.control->should_load_project_icon()) {469_load_project_icon(_icon_load_index);470}471_icon_load_index++;472473// Scan directories in thread to avoid blocking the window.474} else if (scan_data && scan_data->scan_in_progress.is_set()) {475// Wait for the thread.476} else {477set_process(false);478if (scan_data) {479_scan_finished();480}481}482} break;483484case NOTIFICATION_ACCESSIBILITY_UPDATE: {485RID ae = get_accessibility_element();486ERR_FAIL_COND(ae.is_null());487488DisplayServer::get_singleton()->accessibility_update_set_role(ae, DisplayServer::AccessibilityRole::ROLE_LIST_BOX);489DisplayServer::get_singleton()->accessibility_update_set_list_item_count(ae, _projects.size());490DisplayServer::get_singleton()->accessibility_update_set_flag(ae, DisplayServer::AccessibilityFlags::FLAG_MULTISELECTABLE, false);491}492}493}494495// Projects scan.496497void ProjectList::_scan_thread(void *p_scan_data) {498ScanData *scan_data = static_cast<ScanData *>(p_scan_data);499500for (const String &base_path : scan_data->paths_to_scan) {501print_verbose(vformat("Scanning for projects in \"%s\".", base_path));502_scan_folder_recursive(base_path, &scan_data->found_projects, scan_data->scan_in_progress);503504if (!scan_data->scan_in_progress.is_set()) {505print_verbose("Scan aborted.");506break;507}508}509print_verbose(vformat("Found %d project(s).", scan_data->found_projects.size()));510scan_data->scan_in_progress.clear();511}512513void ProjectList::_scan_finished() {514if (scan_data->scan_in_progress.is_set()) {515// Abort scanning.516scan_data->scan_in_progress.clear();517}518519scan_data->thread->wait_to_finish();520memdelete(scan_data->thread);521if (scan_progress) {522scan_progress->hide();523}524525for (const String &E : scan_data->found_projects) {526add_project(E, false);527}528memdelete(scan_data);529scan_data = nullptr;530531save_config();532533if (ProjectManager::get_singleton()->is_initialized()) {534update_project_list();535}536}537538// Initialization & loading.539540void ProjectList::_migrate_config() {541// Proposal #1637 moved the project list from editor settings to a separate config file542// If the new config file doesn't exist, populate it from EditorSettings543if (FileAccess::exists(_config_path)) {544return;545}546547List<PropertyInfo> properties;548EditorSettings::get_singleton()->get_property_list(&properties);549550for (const PropertyInfo &E : properties) {551// This is actually something like "projects/C:::Documents::Godot::Projects::MyGame"552String property_key = E.name;553if (!property_key.begins_with("projects/")) {554continue;555}556557String path = EDITOR_GET(property_key);558print_line("Migrating legacy project '" + path + "'.");559560String favoriteKey = "favorite_projects/" + property_key.get_slicec('/', 1);561bool favorite = EditorSettings::get_singleton()->has_setting(favoriteKey);562add_project(path, favorite);563if (favorite) {564EditorSettings::get_singleton()->erase(favoriteKey);565}566EditorSettings::get_singleton()->erase(property_key);567}568569save_config();570}571572void ProjectList::save_config() {573_config.save(_config_path);574}575576// Load project data from p_property_key and return it in a ProjectList::Item.577// p_favorite is passed directly into the Item.578ProjectList::Item ProjectList::load_project_data(const String &p_path, bool p_favorite) {579String conf = p_path.path_join("project.godot");580bool grayed = false;581bool missing = false;582bool recovery_mode = false;583584Ref<ConfigFile> cf = memnew(ConfigFile);585Error cf_err = cf->load(conf);586587int config_version = 0;588String cf_project_name;589String project_name = TTR("Unnamed Project");590if (cf_err == OK) {591cf_project_name = cf->get_value("application", "config/name", "");592if (!cf_project_name.is_empty()) {593project_name = cf_project_name.xml_unescape();594}595config_version = (int)cf->get_value("", "config_version", 0);596}597598if (config_version > ProjectSettings::CONFIG_VERSION) {599// Comes from an incompatible (more recent) Godot version, gray it out.600grayed = true;601}602603const String description = cf->get_value("application", "config/description", "");604const PackedStringArray tags = cf->get_value("application", "config/tags", PackedStringArray());605const String main_scene = cf->get_value("application", "run/main_scene", "");606607String icon = cf->get_value("application", "config/icon", "");608if (icon.begins_with("uid://")) {609Error err;610Ref<FileAccess> file = FileAccess::open(p_path.path_join(".godot/uid_cache.bin"), FileAccess::READ, &err);611if (err == OK) {612icon = ResourceUID::get_path_from_cache(file, icon);613if (icon.is_empty()) {614WARN_PRINT(vformat("Could not load icon from UID for project at path \"%s\". Make sure UID cache exists.", p_path));615}616} else {617// Cache does not exist yet, so ignore and fallback to default icon.618icon = "";619}620}621622PackedStringArray project_features = cf->get_value("application", "config/features", PackedStringArray());623PackedStringArray unsupported_features = ProjectSettings::get_unsupported_features(project_features);624625String project_version = "?";626for (int i = 0; i < project_features.size(); i++) {627if (ProjectList::project_feature_looks_like_version(project_features[i])) {628project_version = project_features[i];629break;630}631}632633if (config_version < ProjectSettings::CONFIG_VERSION) {634// Previous versions may not have unsupported features.635if (config_version == 4) {636unsupported_features.push_back("3.x");637project_version = "3.x";638} else {639unsupported_features.push_back(TTR("Unknown version"));640}641}642643uint64_t last_edited = 0;644if (cf_err == OK) {645// The modification date marks the date the project was last edited.646// This is because the `project.godot` file will always be modified647// when editing a project (but not when running it).648last_edited = FileAccess::get_modified_time(conf);649650String fscache = p_path.path_join(".fscache");651if (FileAccess::exists(fscache)) {652uint64_t cache_modified = FileAccess::get_modified_time(fscache);653if (cache_modified > last_edited) {654last_edited = cache_modified;655}656}657} else {658grayed = true;659missing = true;660}661662for (const String &tag : tags) {663ProjectManager::get_singleton()->add_new_tag(tag);664}665666// We can't use OS::get_user_dir() because it attempts to load paths from the current loaded project through ProjectSettings,667// while here we're parsing project files externally. Therefore, we have to replicate its behavior.668String user_dir;669if (!cf_project_name.is_empty()) {670String appname = OS::get_singleton()->get_safe_dir_name(cf_project_name);671bool use_custom_dir = cf->get_value("application", "config/use_custom_user_dir", false);672if (use_custom_dir) {673String custom_dir = OS::get_singleton()->get_safe_dir_name(cf->get_value("application", "config/custom_user_dir_name", ""), true);674if (custom_dir.is_empty()) {675custom_dir = appname;676}677user_dir = custom_dir;678} else {679user_dir = OS::get_singleton()->get_godot_dir_name().path_join("app_userdata").path_join(appname);680}681} else {682user_dir = OS::get_singleton()->get_godot_dir_name().path_join("app_userdata").path_join("[unnamed project]");683}684685String recovery_mode_lock_file = OS::get_singleton()->get_user_data_dir(user_dir).path_join(".recovery_mode_lock");686recovery_mode = FileAccess::exists(recovery_mode_lock_file);687688return Item(project_name, description, project_version, tags, p_path, icon, main_scene, unsupported_features, last_edited, p_favorite, grayed, missing, recovery_mode, config_version);689}690691void ProjectList::_update_icons_async() {692_icon_load_index = 0;693set_process(true);694}695696void ProjectList::_load_project_icon(int p_index) {697Item &item = _projects.write[p_index];698699Ref<Texture2D> default_icon = get_editor_theme_icon(SNAME("DefaultProjectIcon"));700Ref<Texture2D> icon;701if (!item.icon.is_empty()) {702Ref<Image> img;703img.instantiate();704Error err = img->load(item.icon.replace_first("res://", item.path + "/"));705if (err == OK) {706img->resize(default_icon->get_width(), default_icon->get_height(), Image::INTERPOLATE_LANCZOS);707icon = ImageTexture::create_from_image(img);708}709}710if (icon.is_null()) {711icon = default_icon;712}713714item.control->set_project_icon(icon);715}716717// Project list updates.718719void ProjectList::update_project_list() {720// This is a full, hard reload of the list. Don't call this unless really required, it's expensive.721// If you have 150 projects, it may read through 150 files on your disk at once + load 150 icons.722// FIXME: Does it really have to be a full, hard reload? Runtime updates should be made much cheaper.723724if (ProjectManager::get_singleton()->is_initialized()) {725// Clear whole list726for (int i = 0; i < _projects.size(); ++i) {727Item &project = _projects.write[i];728CRASH_COND(project.control == nullptr);729memdelete(project.control); // Why not queue_free()?730}731732_projects.clear();733_last_clicked = "";734_selected_project_paths.clear();735736load_project_list();737}738739// Create controls740for (int i = 0; i < _projects.size(); ++i) {741_create_project_item_control(i);742}743744sort_projects();745_update_icons_async();746update_dock_menu();747748set_v_scroll(0);749emit_signal(SNAME(SIGNAL_LIST_CHANGED));750queue_accessibility_update();751}752753void ProjectList::sort_projects() {754SortArray<Item, ProjectListComparator> sorter;755sorter.compare.order_option = _order_option;756sorter.sort(_projects.ptrw(), _projects.size());757758String search_term;759PackedStringArray tags;760761if (!_search_term.is_empty()) {762PackedStringArray search_parts = _search_term.split(" ");763if (search_parts.size() > 1 || search_parts[0].begins_with("tag:")) {764PackedStringArray remaining;765for (const String &part : search_parts) {766if (part.begins_with("tag:")) {767tags.push_back(part.get_slicec(':', 1));768} else {769remaining.append(part);770}771}772search_term = String(" ").join(remaining); // Search term without tags.773} else {774search_term = _search_term;775}776}777778for (int i = 0; i < _projects.size(); ++i) {779Item &item = _projects.write[i];780781bool item_visible = true;782if (!_search_term.is_empty()) {783String search_path;784if (search_term.contains_char('/')) {785// Search path will match the whole path786search_path = item.path;787} else {788// Search path will only match the last path component to make searching more strict789search_path = item.path.get_file();790}791792bool missing_tags = false;793for (const String &tag : tags) {794if (!item.tags.has(tag)) {795missing_tags = true;796break;797}798}799800// When searching, display projects whose name or path contain the search term and whose tags match the searched tags.801item_visible = !missing_tags && (search_term.is_empty() || item.project_name.containsn(search_term) || search_path.containsn(search_term));802}803804item.control->set_visible(item_visible);805}806807for (int i = 0; i < _projects.size(); ++i) {808Item &item = _projects.write[i];809item.control->get_parent()->move_child(item.control, i);810}811812// Rewind the coroutine because order of projects changed813_update_icons_async();814update_dock_menu();815queue_accessibility_update();816}817818int ProjectList::get_project_count() const {819return _projects.size();820}821822void ProjectList::find_projects(const String &p_path) {823PackedStringArray paths = { p_path };824find_projects_multiple(paths);825}826827void ProjectList::find_projects_multiple(const PackedStringArray &p_paths) {828if (!scan_progress && is_inside_tree()) {829scan_progress = memnew(AcceptDialog);830scan_progress->set_title(TTRC("Scanning"));831scan_progress->set_ok_button_text(TTRC("Cancel"));832833VBoxContainer *vb = memnew(VBoxContainer);834scan_progress->add_child(vb);835836Label *label = memnew(Label);837label->set_text(TTRC("Scanning for projects..."));838vb->add_child(label);839840ProgressBar *progress = memnew(ProgressBar);841progress->set_indeterminate(true);842vb->add_child(progress);843844add_child(scan_progress);845scan_progress->connect(SceneStringName(confirmed), callable_mp(this, &ProjectList::_scan_finished));846scan_progress->connect("canceled", callable_mp(this, &ProjectList::_scan_finished));847}848849scan_data = memnew(ScanData);850scan_data->paths_to_scan = p_paths;851scan_data->scan_in_progress.set();852853scan_data->thread = memnew(Thread);854scan_data->thread->start(_scan_thread, scan_data);855856if (scan_progress) {857scan_progress->reset_size();858scan_progress->popup_centered();859}860set_process(true);861}862863void ProjectList::load_project_list() {864_config.load(_config_path);865Vector<String> sections = _config.get_sections();866867for (const String &path : sections) {868bool favorite = _config.get_value(path, "favorite", false);869_projects.push_back(load_project_data(path, favorite));870}871}872873void ProjectList::_scan_folder_recursive(const String &p_path, List<String> *r_projects, const SafeFlag &p_scan_active) {874if (!p_scan_active.is_set()) {875return;876}877878Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);879Error error = da->change_dir(p_path);880ERR_FAIL_COND_MSG(error != OK, vformat("Failed to open the path \"%s\" for scanning (code %d).", p_path, error));881882da->list_dir_begin();883String n = da->get_next();884while (!n.is_empty()) {885if (!p_scan_active.is_set()) {886return;887}888889if (da->current_is_dir() && n[0] != '.') {890_scan_folder_recursive(da->get_current_dir().path_join(n), r_projects, p_scan_active);891} else if (n == "project.godot") {892r_projects->push_back(da->get_current_dir());893}894n = da->get_next();895}896da->list_dir_end();897}898899// Project list items.900901void ProjectList::add_project(const String &dir_path, bool favorite) {902if (!_config.has_section(dir_path)) {903_config.set_value(dir_path, "favorite", favorite);904}905queue_accessibility_update();906}907908void ProjectList::set_project_version(const String &p_project_path, int p_version) {909for (ProjectList::Item &E : _projects) {910if (E.path == p_project_path) {911E.version = p_version;912break;913}914}915}916917int ProjectList::refresh_project(const String &dir_path) {918// Reloads information about a specific project.919// If it wasn't loaded and should be in the list, it is added (i.e new project).920// If it isn't in the list anymore, it is removed.921// If it is in the list but doesn't exist anymore, it is marked as missing.922923bool should_be_in_list = _config.has_section(dir_path);924bool is_favourite = _config.get_value(dir_path, "favorite", false);925926bool was_selected = _selected_project_paths.has(dir_path);927928// Remove item in any case929for (int i = 0; i < _projects.size(); ++i) {930const Item &existing_item = _projects[i];931if (existing_item.path == dir_path) {932_remove_project(i, false);933break;934}935}936937int index = -1;938if (should_be_in_list) {939// Recreate it with updated info940941Item item = load_project_data(dir_path, is_favourite);942943_projects.push_back(item);944_create_project_item_control(_projects.size() - 1);945946sort_projects();947948for (int i = 0; i < _projects.size(); ++i) {949if (_projects[i].path == dir_path) {950if (was_selected) {951select_project(i);952ensure_project_visible(i);953}954_load_project_icon(i);955956index = i;957break;958}959}960}961962return index;963}964965int ProjectList::get_index(const ProjectListItemControl *p_control) const {966for (int i = 0; i < _projects.size(); ++i) {967if (_projects[i].control == p_control) {968return i;969}970}971return -1;972}973974void ProjectList::ensure_project_visible(int p_index) {975const Item &item = _projects[p_index];976ensure_control_visible(item.control);977}978979void ProjectList::_create_project_item_control(int p_index) {980// Will be added last in the list, so make sure indexes match981ERR_FAIL_COND(p_index != project_list_vbox->get_child_count());982983Item &item = _projects.write[p_index];984ERR_FAIL_COND(item.control != nullptr); // Already created985986ProjectListItemControl *hb = memnew(ProjectListItemControl);987hb->add_theme_constant_override("separation", 10 * EDSCALE);988989hb->set_project_title(!item.missing ? item.project_name : TTR("Missing Project"));990hb->set_project_path(item.path);991hb->set_tooltip_text(item.description);992hb->set_tags(item.tags, this);993hb->set_unsupported_features(item.unsupported_features.duplicate());994hb->set_project_version(item.project_version);995hb->set_last_edited_info(item.get_last_edited_string());996997hb->set_is_favorite(item.favorite);998hb->set_is_missing(item.missing);999hb->set_is_grayed(item.grayed);10001001hb->connect(SceneStringName(gui_input), callable_mp(this, &ProjectList::_list_item_input).bind(hb));1002hb->connect("favorite_pressed", callable_mp(this, &ProjectList::_on_favorite_pressed).bind(hb));10031004#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)1005hb->connect("explore_pressed", callable_mp(this, &ProjectList::_on_explore_pressed).bind(item.path));1006#endif10071008project_list_vbox->add_child(hb);1009item.control = hb;1010}10111012void ProjectList::_toggle_project(int p_index) {1013// This methods adds to the selection or removes from the1014// selection.1015Item &item = _projects.write[p_index];10161017if (_selected_project_paths.has(item.path)) {1018_deselect_project_nocheck(p_index);1019} else {1020_select_project_nocheck(p_index);1021}1022}10231024void ProjectList::_remove_project(int p_index, bool p_update_config) {1025const Item item = _projects[p_index]; // Take a copy10261027_selected_project_paths.erase(item.path);10281029if (_last_clicked == item.path) {1030_last_clicked = "";1031}10321033memdelete(item.control);1034_projects.remove_at(p_index);10351036if (p_update_config) {1037_config.erase_section(item.path);1038// Not actually saving the file, in case you are doing more changes to settings1039}10401041queue_accessibility_update();1042update_dock_menu();1043}10441045void ProjectList::_list_item_input(const Ref<InputEvent> &p_ev, Node *p_hb) {1046Ref<InputEventMouseButton> mb = p_ev;1047int clicked_index = p_hb->get_index();1048const Item &clicked_project = _projects[clicked_index];10491050if (mb.is_valid() && mb->is_pressed() && mb->get_button_index() == MouseButton::LEFT) {1051if (mb->is_shift_pressed() && _selected_project_paths.size() > 0 && !_last_clicked.is_empty() && clicked_project.path != _last_clicked) {1052int anchor_index = -1;1053for (int i = 0; i < _projects.size(); ++i) {1054const Item &p = _projects[i];1055if (p.path == _last_clicked) {1056anchor_index = p.control->get_index();1057break;1058}1059}1060CRASH_COND(anchor_index == -1);1061_select_project_range(anchor_index, clicked_index);10621063} else if (mb->is_command_or_control_pressed()) {1064_toggle_project(clicked_index);10651066} else {1067_last_clicked = clicked_project.path;1068select_project(clicked_index);1069}10701071emit_signal(SNAME(SIGNAL_SELECTION_CHANGED));10721073// Do not allow opening a project more than once using a single project manager instance.1074// Opening the same project in several editor instances at once can lead to various issues.1075if (!mb->is_command_or_control_pressed() && mb->is_double_click() && !project_opening_initiated) {1076emit_signal(SNAME(SIGNAL_PROJECT_ASK_OPEN));1077}1078}1079}10801081void ProjectList::_on_favorite_pressed(Node *p_hb) {1082ProjectListItemControl *control = Object::cast_to<ProjectListItemControl>(p_hb);10831084int index = control->get_index();1085Item item = _projects.write[index]; // Take copy10861087item.favorite = !item.favorite;10881089_config.set_value(item.path, "favorite", item.favorite);1090save_config();10911092_projects.write[index] = item;10931094control->set_is_favorite(item.favorite);10951096sort_projects();10971098if (item.favorite) {1099for (int i = 0; i < _projects.size(); ++i) {1100if (_projects[i].path == item.path) {1101ensure_project_visible(i);1102break;1103}1104}1105}11061107update_dock_menu();1108}11091110void ProjectList::_on_explore_pressed(const String &p_path) {1111OS::get_singleton()->shell_show_in_file_manager(p_path, true);1112}11131114// Project list selection.11151116void ProjectList::_clear_project_selection() {1117Vector<Item> previous_selected_items = get_selected_projects();1118_selected_project_paths.clear();11191120for (int i = 0; i < previous_selected_items.size(); ++i) {1121previous_selected_items[i].control->set_selected(false);1122}1123queue_accessibility_update();1124}11251126void ProjectList::_select_project_nocheck(int p_index) {1127Item &item = _projects.write[p_index];1128_selected_project_paths.insert(item.path);1129item.control->set_selected(true);1130queue_accessibility_update();1131}11321133void ProjectList::_deselect_project_nocheck(int p_index) {1134Item &item = _projects.write[p_index];1135_selected_project_paths.erase(item.path);1136item.control->set_selected(false);1137queue_accessibility_update();1138}11391140inline void _sort_project_range(int &a, int &b) {1141if (a > b) {1142int temp = a;1143a = b;1144b = temp;1145}1146}11471148void ProjectList::_select_project_range(int p_begin, int p_end) {1149_clear_project_selection();11501151_sort_project_range(p_begin, p_end);1152for (int i = p_begin; i <= p_end; ++i) {1153_select_project_nocheck(i);1154}1155}11561157void ProjectList::select_project(int p_index) {1158// This method keeps only one project selected.1159_clear_project_selection();1160_select_project_nocheck(p_index);1161}11621163void ProjectList::deselect_project(int p_index) {1164_deselect_project_nocheck(p_index);1165}11661167void ProjectList::select_first_visible_project() {1168_clear_project_selection();11691170for (int i = 0; i < _projects.size(); i++) {1171if (_projects[i].control->is_visible()) {1172_select_project_nocheck(i);1173break;1174}1175}1176}11771178Vector<ProjectList::Item> ProjectList::get_selected_projects() const {1179Vector<Item> items;1180if (_selected_project_paths.is_empty()) {1181return items;1182}1183items.resize(_selected_project_paths.size());1184int j = 0;1185for (int i = 0; i < _projects.size(); ++i) {1186const Item &item = _projects[i];1187if (_selected_project_paths.has(item.path)) {1188items.write[j++] = item;1189}1190}1191ERR_FAIL_COND_V(j != items.size(), items);1192return items;1193}11941195const HashSet<String> &ProjectList::get_selected_project_keys() const {1196// Faster if that's all you need1197return _selected_project_paths;1198}11991200int ProjectList::get_single_selected_index() const {1201if (_selected_project_paths.is_empty()) {1202// Default selection1203return 0;1204}1205String key;1206if (_selected_project_paths.size() == 1) {1207// Only one selected1208key = *_selected_project_paths.begin();1209} else {1210// Multiple selected, consider the last clicked one as "main"1211key = _last_clicked;1212}1213for (int i = 0; i < _projects.size(); ++i) {1214if (_projects[i].path == key) {1215return i;1216}1217}1218return 0;1219}12201221void ProjectList::erase_selected_projects(bool p_delete_project_contents) {1222if (_selected_project_paths.is_empty()) {1223return;1224}12251226for (int i = 0; i < _projects.size(); ++i) {1227Item &item = _projects.write[i];1228if (_selected_project_paths.has(item.path) && item.control->is_visible()) {1229_config.erase_section(item.path);12301231// Comment out for now until we have a better warning system to1232// ensure users delete their project only.1233//if (p_delete_project_contents) {1234// OS::get_singleton()->move_to_trash(item.path);1235//}12361237memdelete(item.control);1238_projects.remove_at(i);1239--i;1240}1241}12421243save_config();1244_selected_project_paths.clear();1245_last_clicked = "";12461247update_dock_menu();1248}12491250// Missing projects.12511252bool ProjectList::is_any_project_missing() const {1253for (int i = 0; i < _projects.size(); ++i) {1254if (_projects[i].missing) {1255return true;1256}1257}1258return false;1259}12601261void ProjectList::erase_missing_projects() {1262if (_projects.is_empty()) {1263return;1264}12651266int deleted_count = 0;1267int remaining_count = 0;12681269for (int i = 0; i < _projects.size(); ++i) {1270const Item &item = _projects[i];12711272if (item.missing) {1273_remove_project(i, true);1274--i;1275++deleted_count;12761277} else {1278++remaining_count;1279}1280}12811282print_line("Removed " + itos(deleted_count) + " projects from the list, remaining " + itos(remaining_count) + " projects");1283save_config();1284}12851286// Project list sorting and filtering.12871288void ProjectList::set_search_term(String p_search_term) {1289_search_term = p_search_term;1290}12911292void ProjectList::add_search_tag(const String &p_tag) {1293const String tag_string = "tag:" + p_tag;12941295int exists = _search_term.find(tag_string);1296if (exists > -1) {1297_search_term = _search_term.erase(exists, tag_string.length() + 1);1298} else if (_search_term.is_empty() || _search_term.ends_with(" ")) {1299_search_term += tag_string;1300} else {1301_search_term += " " + tag_string;1302}1303ProjectManager::get_singleton()->get_search_box()->set_text(_search_term);13041305sort_projects();1306}13071308void ProjectList::set_order_option(int p_option) {1309FilterOption selected = (FilterOption)p_option;1310EditorSettings::get_singleton()->set("project_manager/sorting_order", p_option);1311EditorSettings::get_singleton()->save();1312_order_option = selected;13131314sort_projects();1315}13161317// Global menu integration.13181319void ProjectList::update_dock_menu() {1320if (!NativeMenu::get_singleton()->has_feature(NativeMenu::FEATURE_GLOBAL_MENU)) {1321return;1322}1323RID dock_rid = NativeMenu::get_singleton()->get_system_menu(NativeMenu::DOCK_MENU_ID);1324NativeMenu::get_singleton()->clear(dock_rid);13251326int favs_added = 0;1327int total_added = 0;1328for (int i = 0; i < _projects.size(); ++i) {1329if (!_projects[i].grayed && !_projects[i].missing) {1330if (_projects[i].favorite) {1331favs_added++;1332} else {1333if (favs_added != 0) {1334NativeMenu::get_singleton()->add_separator(dock_rid);1335}1336favs_added = 0;1337}1338NativeMenu::get_singleton()->add_item(dock_rid, _projects[i].project_name + " ( " + _projects[i].path + " )", callable_mp(this, &ProjectList::_global_menu_open_project), Callable(), i);1339total_added++;1340}1341}1342if (total_added != 0) {1343NativeMenu::get_singleton()->add_separator(dock_rid);1344}1345NativeMenu::get_singleton()->add_item(dock_rid, TTR("New Window"), callable_mp(this, &ProjectList::_global_menu_new_window));1346}13471348void ProjectList::_global_menu_new_window(const Variant &p_tag) {1349List<String> args;1350args.push_back("-p");1351OS::get_singleton()->create_instance(args);1352}13531354void ProjectList::_global_menu_open_project(const Variant &p_tag) {1355int idx = (int)p_tag;13561357if (idx >= 0 && idx < _projects.size()) {1358String conf = _projects[idx].path.path_join("project.godot");1359List<String> args;1360args.push_back(conf);1361OS::get_singleton()->create_instance(args);1362}1363}13641365// Object methods.13661367void ProjectList::_bind_methods() {1368ADD_SIGNAL(MethodInfo(SIGNAL_LIST_CHANGED));1369ADD_SIGNAL(MethodInfo(SIGNAL_SELECTION_CHANGED));1370ADD_SIGNAL(MethodInfo(SIGNAL_PROJECT_ASK_OPEN));1371}13721373ProjectList::ProjectList() {1374project_list_vbox = memnew(VBoxContainer);1375project_list_vbox->set_h_size_flags(Control::SIZE_EXPAND_FILL);1376add_child(project_list_vbox);13771378_config_path = EditorPaths::get_singleton()->get_data_dir().path_join("projects.cfg");1379_migrate_config();1380}138113821383