Browse Source

Merge pull request #87266 from YuriSizov/pm-gets-a-chris-hemsworth-treatment

Reorganize project manager code
Rémi Verschelde 1 year ago
parent
commit
f3fd668a02

+ 1 - 0
editor/SCsub

@@ -113,6 +113,7 @@ if env.editor_build:
     SConscript("icons/SCsub")
     SConscript("import/SCsub")
     SConscript("plugins/SCsub")
+    SConscript("project_manager/SCsub")
     SConscript("themes/SCsub")
 
     lib = env.add_library("editor", env.editor_sources)

File diff suppressed because it is too large
+ 7 - 1933
editor/project_manager.cpp


+ 75 - 347
editor/project_manager.h

@@ -31,315 +31,68 @@
 #ifndef PROJECT_MANAGER_H
 #define PROJECT_MANAGER_H
 
-#include "core/io/config_file.h"
-#include "editor/editor_about.h"
 #include "scene/gui/dialogs.h"
-#include "scene/gui/file_dialog.h"
 #include "scene/gui/scroll_container.h"
 
 class CheckBox;
+class EditorAbout;
 class EditorAssetLibrary;
 class EditorFileDialog;
 class HFlowContainer;
+class LineEdit;
+class LinkButton;
+class OptionButton;
 class PanelContainer;
+class ProjectDialog;
 class ProjectList;
+class TabContainer;
 
-class ProjectDialog : public ConfirmationDialog {
-	GDCLASS(ProjectDialog, ConfirmationDialog);
-
-public:
-	enum Mode {
-		MODE_NEW,
-		MODE_IMPORT,
-		MODE_INSTALL,
-		MODE_RENAME,
-	};
-
-private:
-	enum MessageType {
-		MESSAGE_ERROR,
-		MESSAGE_WARNING,
-		MESSAGE_SUCCESS,
-	};
-
-	enum InputType {
-		PROJECT_PATH,
-		INSTALL_PATH,
-	};
-
-	Mode mode = MODE_NEW;
-	bool is_folder_empty = true;
-
-	Button *browse = nullptr;
-	Button *install_browse = nullptr;
-	Button *create_dir = nullptr;
-	Container *name_container = nullptr;
-	Container *path_container = nullptr;
-	Container *install_path_container = nullptr;
-
-	Container *renderer_container = nullptr;
-	Label *renderer_info = nullptr;
-	HBoxContainer *default_files_container = nullptr;
-	Ref<ButtonGroup> renderer_button_group;
-
-	Label *msg = nullptr;
-	LineEdit *project_path = nullptr;
-	LineEdit *project_name = nullptr;
-	LineEdit *install_path = nullptr;
-	TextureRect *status_rect = nullptr;
-	TextureRect *install_status_rect = nullptr;
-
-	OptionButton *vcs_metadata_selection = nullptr;
-
-	EditorFileDialog *fdialog = nullptr;
-	EditorFileDialog *fdialog_install = nullptr;
-	AcceptDialog *dialog_error = nullptr;
-
-	String zip_path;
-	String zip_title;
-	String fav_dir;
-
-	String created_folder_path;
-
-	void _set_message(const String &p_msg, MessageType p_type = MESSAGE_SUCCESS, InputType input_type = PROJECT_PATH);
-
-	String _test_path();
-	void _update_path(const String &p_path);
-	void _path_text_changed(const String &p_path);
-	void _path_selected(const String &p_path);
-	void _file_selected(const String &p_path);
-	void _install_path_selected(const String &p_path);
-
-	void _browse_path();
-	void _browse_install_path();
-	void _create_folder();
-
-	void _text_changed(const String &p_text);
-	void _nonempty_confirmation_ok_pressed();
-	void _renderer_selected();
-	void _remove_created_folder();
-
-	void ok_pressed() override;
-	void cancel_pressed() override;
-
-protected:
-	void _notification(int p_what);
-	static void _bind_methods();
-
-public:
-	void set_zip_path(const String &p_path);
-	void set_zip_title(const String &p_title);
-	void set_mode(Mode p_mode);
-	void set_project_path(const String &p_path);
-
-	void ask_for_path_and_show();
-	void show_dialog();
-
-	ProjectDialog();
-};
-
-class ProjectListItemControl : public HBoxContainer {
-	GDCLASS(ProjectListItemControl, HBoxContainer)
-
-	VBoxContainer *main_vbox = nullptr;
-	TextureButton *favorite_button = nullptr;
-	Button *explore_button = nullptr;
-
-	TextureRect *project_icon = nullptr;
-	Label *project_title = nullptr;
-	Label *project_path = nullptr;
-	TextureRect *project_unsupported_features = nullptr;
-	HBoxContainer *tag_container = nullptr;
-
-	bool project_is_missing = false;
-	bool icon_needs_reload = true;
-	bool is_selected = false;
-	bool is_hovering = false;
-
-	void _favorite_button_pressed();
-	void _explore_button_pressed();
-
-protected:
-	void _notification(int p_what);
-	static void _bind_methods();
-
-public:
-	void set_project_title(const String &p_title);
-	void set_project_path(const String &p_path);
-	void set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list);
-	void set_project_icon(const Ref<Texture2D> &p_icon);
-	void set_unsupported_features(PackedStringArray p_features);
-
-	bool should_load_project_icon() const;
-	void set_selected(bool p_selected);
-
-	void set_is_favorite(bool p_favorite);
-	void set_is_missing(bool p_missing);
-	void set_is_grayed(bool p_grayed);
-
-	ProjectListItemControl();
-};
-
-class ProjectList : public ScrollContainer {
-	GDCLASS(ProjectList, ScrollContainer)
+class ProjectManager : public Control {
+	GDCLASS(ProjectManager, Control);
 
-	friend class ProjectManager;
+	static ProjectManager *singleton;
 
-public:
-	enum FilterOption {
-		EDIT_DATE,
-		NAME,
-		PATH,
-		TAGS,
-	};
-
-	// Can often be passed by copy
-	struct Item {
-		String project_name;
-		String description;
-		PackedStringArray tags;
-		String tag_sort_string;
-		String path;
-		String icon;
-		String main_scene;
-		PackedStringArray unsupported_features;
-		uint64_t last_edited = 0;
-		bool favorite = false;
-		bool grayed = false;
-		bool missing = false;
-		int version = 0;
-
-		ProjectListItemControl *control = nullptr;
-
-		Item() {}
-
-		Item(const String &p_name,
-				const String &p_description,
-				const PackedStringArray &p_tags,
-				const String &p_path,
-				const String &p_icon,
-				const String &p_main_scene,
-				const PackedStringArray &p_unsupported_features,
-				uint64_t p_last_edited,
-				bool p_favorite,
-				bool p_grayed,
-				bool p_missing,
-				int p_version) {
-			project_name = p_name;
-			description = p_description;
-			tags = p_tags;
-			path = p_path;
-			icon = p_icon;
-			main_scene = p_main_scene;
-			unsupported_features = p_unsupported_features;
-			last_edited = p_last_edited;
-			favorite = p_favorite;
-			grayed = p_grayed;
-			missing = p_missing;
-			version = p_version;
-			control = nullptr;
-
-			PackedStringArray sorted_tags = tags;
-			sorted_tags.sort();
-			tag_sort_string = String().join(sorted_tags);
-		}
-
-		_FORCE_INLINE_ bool operator==(const Item &l) const {
-			return path == l.path;
-		}
-	};
-
-private:
-	bool project_opening_initiated = false;
-
-	String _search_term;
-	FilterOption _order_option = FilterOption::EDIT_DATE;
-	HashSet<String> _selected_project_paths;
-	String _last_clicked; // Project key
-	VBoxContainer *_scroll_children = nullptr;
-	int _icon_load_index = 0;
-
-	Vector<Item> _projects;
-
-	ConfigFile _config;
-	String _config_path;
-
-	void _panel_input(const Ref<InputEvent> &p_ev, Node *p_hb);
-	void _favorite_pressed(Node *p_hb);
-	void _show_project(const String &p_path);
-
-	void _migrate_config();
-	void _scan_folder_recursive(const String &p_path, List<String> *r_projects);
-
-	void _clear_project_selection();
-	void _toggle_project(int p_index);
-	void _select_project_nocheck(int p_index);
-	void _deselect_project_nocheck(int p_index);
-	void _select_project_range(int p_begin, int p_end);
-
-	void _create_project_item_control(int p_index);
-	void _remove_project(int p_index, bool p_update_settings);
-
-	static Item load_project_data(const String &p_property_key, bool p_favorite);
-	void _update_icons_async();
-	void _load_project_icon(int p_index);
-
-	void _global_menu_new_window(const Variant &p_tag);
-	void _global_menu_open_project(const Variant &p_tag);
+	// Utility data.
 
-protected:
-	void _notification(int p_what);
-	static void _bind_methods();
+	static Ref<Texture2D> _file_dialog_get_icon(const String &p_path);
+	static Ref<Texture2D> _file_dialog_get_thumbnail(const String &p_path);
 
-public:
-	static const char *SIGNAL_LIST_CHANGED;
-	static const char *SIGNAL_SELECTION_CHANGED;
-	static const char *SIGNAL_PROJECT_ASK_OPEN;
+	HashMap<String, Ref<Texture2D>> icon_type_cache;
 
-	void update_project_list();
-	int get_project_count() const;
+	void _build_icon_type_cache(Ref<Theme> p_theme);
 
-	void find_projects(const String &p_path);
-	void find_projects_multiple(const PackedStringArray &p_paths);
-	void sort_projects();
+	// Main layout.
 
-	void add_project(const String &dir_path, bool favorite);
-	void set_project_version(const String &p_project_path, int version);
-	int refresh_project(const String &dir_path);
-	void ensure_project_visible(int p_index);
+	void _update_size_limits();
 
-	void select_project(int p_index);
-	void select_first_visible_project();
-	void erase_selected_projects(bool p_delete_project_contents);
-	Vector<Item> get_selected_projects() const;
-	const HashSet<String> &get_selected_project_keys() const;
-	int get_single_selected_index() const;
+	Panel *background_panel = nullptr;
+	Button *about_btn = nullptr;
+	LinkButton *version_btn = nullptr;
 
-	bool is_any_project_missing() const;
-	void erase_missing_projects();
+	ConfirmationDialog *open_templates = nullptr;
+	EditorAbout *about = nullptr;
 
-	void set_search_term(String p_search_term);
-	void add_search_tag(const String &p_tag);
-	void set_order_option(int p_option);
+	void _show_about();
+	void _version_button_pressed();
 
-	void update_dock_menu();
-	void save_config();
+	TabContainer *tabs = nullptr;
+	VBoxContainer *local_projects_vb = nullptr;
+	EditorAssetLibrary *asset_library = nullptr;
 
-	ProjectList();
-};
+	void _on_tab_changed(int p_tab);
+	void _open_asset_library();
 
-class ProjectManager : public Control {
-	GDCLASS(ProjectManager, Control);
+	// Quick settings.
 
-	HashMap<String, Ref<Texture2D>> icon_type_cache;
-	void _build_icon_type_cache(Ref<Theme> p_theme);
+	OptionButton *language_btn = nullptr;
+	ConfirmationDialog *restart_required_dialog = nullptr;
 
-	static ProjectManager *singleton;
+	void _language_selected(int p_id);
+	void _restart_confirm();
+	void _dim_window();
 
-	void _update_size_limits();
+	// Project list.
 
-	Panel *background_panel = nullptr;
-	TabContainer *tabs = nullptr;
 	ProjectList *_project_list = nullptr;
 
 	LineEdit *search_box = nullptr;
@@ -356,29 +109,17 @@ class ProjectManager : public Control {
 	Button *manage_tags_btn = nullptr;
 	Button *erase_btn = nullptr;
 	Button *erase_missing_btn = nullptr;
-	Button *about_btn = nullptr;
-
-	VBoxContainer *local_projects_vb = nullptr;
-	EditorAssetLibrary *asset_library = nullptr;
-
-	Ref<StyleBox> tag_stylebox;
 
 	EditorFileDialog *scan_dir = nullptr;
-	ConfirmationDialog *language_restart_ask = nullptr;
 
 	ConfirmationDialog *erase_ask = nullptr;
 	Label *erase_ask_label = nullptr;
 	// Comment out for now until we have a better warning system to
 	// ensure users delete their project only.
 	//CheckBox *delete_project_contents = nullptr;
-
 	ConfirmationDialog *erase_missing_ask = nullptr;
 	ConfirmationDialog *multi_open_ask = nullptr;
 	ConfirmationDialog *multi_run_ask = nullptr;
-	ConfirmationDialog *ask_full_convert_dialog = nullptr;
-	ConfirmationDialog *ask_update_settings = nullptr;
-	ConfirmationDialog *open_templates = nullptr;
-	EditorAbout *about = nullptr;
 
 	HBoxContainer *settings_hb = nullptr;
 
@@ -386,30 +127,13 @@ class ProjectManager : public Control {
 	AcceptDialog *dialog_error = nullptr;
 	ProjectDialog *npdialog = nullptr;
 
-	Button *full_convert_button = nullptr;
-	OptionButton *language_btn = nullptr;
-	LinkButton *version_btn = nullptr;
-
-	HashSet<String> tag_set;
-	PackedStringArray current_project_tags;
-	PackedStringArray forbidden_tag_characters{ "/", "\\", "-" };
-	ConfirmationDialog *tag_manage_dialog = nullptr;
-	HFlowContainer *project_tags = nullptr;
-	HFlowContainer *all_tags = nullptr;
-	Label *tag_edit_error = nullptr;
-	Button *create_tag_btn = nullptr;
-	ConfirmationDialog *create_tag_dialog = nullptr;
-	LineEdit *new_tag_name = nullptr;
-	Label *tag_error = nullptr;
-
-	void _open_asset_library();
 	void _scan_projects();
 	void _run_project();
 	void _run_project_confirm();
 	void _open_selected_projects();
 	void _open_selected_projects_ask();
-	void _full_convert_button_pressed();
-	void _perform_full_project_conversion();
+
+	void _install_project(const String &p_zip_path, const String &p_title);
 	void _import_project();
 	void _new_project();
 	void _rename_project();
@@ -417,11 +141,30 @@ class ProjectManager : public Control {
 	void _erase_missing_projects();
 	void _erase_project_confirm();
 	void _erase_missing_projects_confirm();
-	void _show_about();
 	void _update_project_buttons();
-	void _language_selected(int p_id);
-	void _restart_confirm();
-	void _confirm_update_settings();
+
+	void _on_project_created(const String &dir);
+	void _on_projects_updated();
+
+	void _on_order_option_changed(int p_idx);
+	void _on_search_term_changed(const String &p_term);
+	void _on_search_term_submitted(const String &p_text);
+
+	// Project tag management.
+
+	HashSet<String> tag_set;
+	PackedStringArray current_project_tags;
+	PackedStringArray forbidden_tag_characters{ "/", "\\", "-" };
+
+	ConfirmationDialog *tag_manage_dialog = nullptr;
+	HFlowContainer *project_tags = nullptr;
+	HFlowContainer *all_tags = nullptr;
+	Label *tag_edit_error = nullptr;
+
+	Button *create_tag_btn = nullptr;
+	ConfirmationDialog *create_tag_dialog = nullptr;
+	LineEdit *new_tag_name = nullptr;
+	Label *tag_error = nullptr;
 
 	void _manage_project_tags();
 	void _add_project_tag(const String &p_tag);
@@ -430,23 +173,20 @@ class ProjectManager : public Control {
 	void _set_new_tag_name(const String p_name);
 	void _create_new_tag();
 
-	void _on_project_created(const String &dir);
-	void _on_projects_updated();
+	// Project converter/migration tool.
 
-	void _install_project(const String &p_zip_path, const String &p_title);
+	ConfirmationDialog *ask_full_convert_dialog = nullptr;
+	ConfirmationDialog *ask_update_settings = nullptr;
+	Button *full_convert_button = nullptr;
 
-	void _dim_window();
-	virtual void shortcut_input(const Ref<InputEvent> &p_ev) override;
-	void _files_dropped(PackedStringArray p_files);
+	void _full_convert_button_pressed();
+	void _perform_full_project_conversion();
 
-	void _version_button_pressed();
-	void _on_order_option_changed(int p_idx);
-	void _on_tab_changed(int p_tab);
-	void _on_search_term_changed(const String &p_term);
-	void _on_search_term_submitted(const String &p_text);
+	// Input and I/O.
 
-	static Ref<Texture2D> _file_dialog_get_icon(const String &p_path);
-	static Ref<Texture2D> _file_dialog_get_thumbnail(const String &p_path);
+	virtual void shortcut_input(const Ref<InputEvent> &p_ev) override;
+
+	void _files_dropped(PackedStringArray p_files);
 
 protected:
 	void _notification(int p_what);
@@ -454,28 +194,16 @@ protected:
 public:
 	static ProjectManager *get_singleton() { return singleton; }
 
+	// Project list.
+
 	LineEdit *get_search_box();
+
+	// Project tag management.
+
 	void add_new_tag(const String &p_tag);
 
 	ProjectManager();
 	~ProjectManager();
 };
 
-class ProjectTag : public HBoxContainer {
-	GDCLASS(ProjectTag, HBoxContainer);
-
-	String tag_string;
-	Button *button = nullptr;
-	bool display_close = false;
-
-protected:
-	void _notification(int p_what);
-
-public:
-	ProjectTag(const String &p_text, bool p_display_close = false);
-
-	void connect_button_to(const Callable &p_callable);
-	const String get_tag() const;
-};
-
 #endif // PROJECT_MANAGER_H

+ 5 - 0
editor/project_manager/SCsub

@@ -0,0 +1,5 @@
+#!/usr/bin/env python
+
+Import("env")
+
+env.add_source_files(env.editor_sources, "*.cpp")

+ 977 - 0
editor/project_manager/project_dialog.cpp

@@ -0,0 +1,977 @@
+/**************************************************************************/
+/*  project_dialog.cpp                                                    */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#include "project_dialog.h"
+
+#include "core/config/project_settings.h"
+#include "core/io/dir_access.h"
+#include "core/io/zip_io.h"
+#include "core/version.h"
+#include "editor/editor_settings.h"
+#include "editor/editor_string_names.h"
+#include "editor/editor_vcs_interface.h"
+#include "editor/gui/editor_file_dialog.h"
+#include "editor/themes/editor_icons.h"
+#include "editor/themes/editor_scale.h"
+#include "scene/gui/check_box.h"
+#include "scene/gui/line_edit.h"
+#include "scene/gui/option_button.h"
+#include "scene/gui/separator.h"
+#include "scene/gui/texture_rect.h"
+
+void ProjectDialog::_set_message(const String &p_msg, MessageType p_type, InputType input_type) {
+	msg->set_text(p_msg);
+	Ref<Texture2D> current_path_icon = status_rect->get_texture();
+	Ref<Texture2D> current_install_icon = install_status_rect->get_texture();
+	Ref<Texture2D> new_icon;
+
+	switch (p_type) {
+		case MESSAGE_ERROR: {
+			msg->add_theme_color_override("font_color", get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
+			msg->set_modulate(Color(1, 1, 1, 1));
+			new_icon = get_editor_theme_icon(SNAME("StatusError"));
+
+		} break;
+		case MESSAGE_WARNING: {
+			msg->add_theme_color_override("font_color", get_theme_color(SNAME("warning_color"), EditorStringName(Editor)));
+			msg->set_modulate(Color(1, 1, 1, 1));
+			new_icon = get_editor_theme_icon(SNAME("StatusWarning"));
+
+		} break;
+		case MESSAGE_SUCCESS: {
+			msg->remove_theme_color_override("font_color");
+			msg->set_modulate(Color(1, 1, 1, 0));
+			new_icon = get_editor_theme_icon(SNAME("StatusSuccess"));
+
+		} break;
+	}
+
+	if (current_path_icon != new_icon && input_type == PROJECT_PATH) {
+		status_rect->set_texture(new_icon);
+	} else if (current_install_icon != new_icon && input_type == INSTALL_PATH) {
+		install_status_rect->set_texture(new_icon);
+	}
+}
+
+static bool is_zip_file(Ref<DirAccess> p_d, const String &p_path) {
+	return p_path.ends_with(".zip") && p_d->file_exists(p_path);
+}
+
+String ProjectDialog::_test_path() {
+	Ref<DirAccess> d = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+	const String base_path = project_path->get_text();
+	String valid_path, valid_install_path;
+	bool is_zip = false;
+	if (d->change_dir(base_path) == OK) {
+		valid_path = base_path;
+	} else if (is_zip_file(d, base_path)) {
+		valid_path = base_path;
+		is_zip = true;
+	} else if (d->change_dir(base_path.strip_edges()) == OK) {
+		valid_path = base_path.strip_edges();
+	} else if (is_zip_file(d, base_path.strip_edges())) {
+		valid_path = base_path.strip_edges();
+		is_zip = true;
+	}
+
+	if (valid_path.is_empty()) {
+		_set_message(TTR("The path specified doesn't exist."), MESSAGE_ERROR);
+		get_ok_button()->set_disabled(true);
+		return "";
+	}
+
+	if (mode == MODE_IMPORT && is_zip) {
+		if (d->change_dir(install_path->get_text()) == OK) {
+			valid_install_path = install_path->get_text();
+		} else if (d->change_dir(install_path->get_text().strip_edges()) == OK) {
+			valid_install_path = install_path->get_text().strip_edges();
+		}
+
+		if (valid_install_path.is_empty()) {
+			_set_message(TTR("The install path specified doesn't exist."), MESSAGE_ERROR, INSTALL_PATH);
+			get_ok_button()->set_disabled(true);
+			return "";
+		}
+	}
+
+	if (mode == MODE_IMPORT || mode == MODE_RENAME) {
+		if (!d->file_exists("project.godot")) {
+			if (is_zip) {
+				Ref<FileAccess> io_fa;
+				zlib_filefunc_def io = zipio_create_io(&io_fa);
+
+				unzFile pkg = unzOpen2(valid_path.utf8().get_data(), &io);
+				if (!pkg) {
+					_set_message(TTR("Error opening package file (it's not in ZIP format)."), MESSAGE_ERROR);
+					get_ok_button()->set_disabled(true);
+					unzClose(pkg);
+					return "";
+				}
+
+				int ret = unzGoToFirstFile(pkg);
+				while (ret == UNZ_OK) {
+					unz_file_info info;
+					char fname[16384];
+					ret = unzGetCurrentFileInfo(pkg, &info, fname, 16384, nullptr, 0, nullptr, 0);
+					if (ret != UNZ_OK) {
+						break;
+					}
+
+					if (String::utf8(fname).ends_with("project.godot")) {
+						break;
+					}
+
+					ret = unzGoToNextFile(pkg);
+				}
+
+				if (ret == UNZ_END_OF_LIST_OF_FILE) {
+					_set_message(TTR("Invalid \".zip\" project file; it doesn't contain a \"project.godot\" file."), MESSAGE_ERROR);
+					get_ok_button()->set_disabled(true);
+					unzClose(pkg);
+					return "";
+				}
+
+				unzClose(pkg);
+
+				// check if the specified install folder is empty, even though this is not an error, it is good to check here
+				d->list_dir_begin();
+				is_folder_empty = true;
+				String n = d->get_next();
+				while (!n.is_empty()) {
+					if (!n.begins_with(".")) {
+						// Allow `.`, `..` (reserved current/parent folder names)
+						// and hidden files/folders to be present.
+						// For instance, this lets users initialize a Git repository
+						// and still be able to create a project in the directory afterwards.
+						is_folder_empty = false;
+						break;
+					}
+					n = d->get_next();
+				}
+				d->list_dir_end();
+
+				if (!is_folder_empty) {
+					_set_message(TTR("Please choose an empty install folder."), MESSAGE_WARNING, INSTALL_PATH);
+					get_ok_button()->set_disabled(true);
+					return "";
+				}
+
+			} else {
+				_set_message(TTR("Please choose a \"project.godot\", a directory with it, or a \".zip\" file."), MESSAGE_ERROR);
+				install_path_container->hide();
+				get_ok_button()->set_disabled(true);
+				return "";
+			}
+
+		} else if (is_zip) {
+			_set_message(TTR("The install directory already contains a Godot project."), MESSAGE_ERROR, INSTALL_PATH);
+			get_ok_button()->set_disabled(true);
+			return "";
+		}
+
+	} else {
+		// Check if the specified folder is empty, even though this is not an error, it is good to check here.
+		d->list_dir_begin();
+		is_folder_empty = true;
+		String n = d->get_next();
+		while (!n.is_empty()) {
+			if (!n.begins_with(".")) {
+				// Allow `.`, `..` (reserved current/parent folder names)
+				// and hidden files/folders to be present.
+				// For instance, this lets users initialize a Git repository
+				// and still be able to create a project in the directory afterwards.
+				is_folder_empty = false;
+				break;
+			}
+			n = d->get_next();
+		}
+		d->list_dir_end();
+
+		if (!is_folder_empty) {
+			if (valid_path == OS::get_singleton()->get_environment("HOME") || valid_path == OS::get_singleton()->get_system_dir(OS::SYSTEM_DIR_DOCUMENTS) || valid_path == OS::get_singleton()->get_executable_path().get_base_dir()) {
+				_set_message(TTR("You cannot save a project in the selected path. Please make a new folder or choose a new path."), MESSAGE_ERROR);
+				get_ok_button()->set_disabled(true);
+				return "";
+			}
+
+			_set_message(TTR("The selected path is not empty. Choosing an empty folder is highly recommended."), MESSAGE_WARNING);
+			get_ok_button()->set_disabled(false);
+			return valid_path;
+		}
+	}
+
+	_set_message("");
+	_set_message("", MESSAGE_SUCCESS, INSTALL_PATH);
+	get_ok_button()->set_disabled(false);
+	return valid_path;
+}
+
+void ProjectDialog::_update_path(const String &p_path) {
+	String sp = _test_path();
+	if (!sp.is_empty()) {
+		// If the project name is empty or default, infer the project name from the selected folder name
+		if (project_name->get_text().strip_edges().is_empty() || project_name->get_text().strip_edges() == TTR("New Game Project")) {
+			sp = sp.replace("\\", "/");
+			int lidx = sp.rfind("/");
+
+			if (lidx != -1) {
+				sp = sp.substr(lidx + 1, sp.length()).capitalize();
+			}
+			if (sp.is_empty() && mode == MODE_IMPORT) {
+				sp = TTR("Imported Project");
+			}
+
+			project_name->set_text(sp);
+			_text_changed(sp);
+		}
+	}
+
+	if (!created_folder_path.is_empty() && created_folder_path != p_path) {
+		_remove_created_folder();
+	}
+}
+
+void ProjectDialog::_path_text_changed(const String &p_path) {
+	Ref<DirAccess> d = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+	if (mode == MODE_IMPORT && is_zip_file(d, p_path)) {
+		install_path->set_text(p_path.get_base_dir());
+		install_path_container->show();
+	} else if (mode == MODE_IMPORT && is_zip_file(d, p_path.strip_edges())) {
+		install_path->set_text(p_path.strip_edges().get_base_dir());
+		install_path_container->show();
+	} else {
+		install_path_container->hide();
+	}
+
+	_update_path(p_path.simplify_path());
+}
+
+void ProjectDialog::_file_selected(const String &p_path) {
+	// If not already shown.
+	show_dialog();
+
+	String p = p_path;
+	if (mode == MODE_IMPORT) {
+		if (p.ends_with("project.godot")) {
+			p = p.get_base_dir();
+			install_path_container->hide();
+			get_ok_button()->set_disabled(false);
+		} else if (p.ends_with(".zip")) {
+			install_path->set_text(p.get_base_dir());
+			install_path_container->show();
+			get_ok_button()->set_disabled(false);
+		} else {
+			_set_message(TTR("Please choose a \"project.godot\" or \".zip\" file."), MESSAGE_ERROR);
+			get_ok_button()->set_disabled(true);
+			return;
+		}
+	}
+
+	String sp = p.simplify_path();
+	project_path->set_text(sp);
+	_update_path(sp);
+	if (p.ends_with(".zip")) {
+		callable_mp((Control *)install_path, &Control::grab_focus).call_deferred();
+	} else {
+		callable_mp((Control *)get_ok_button(), &Control::grab_focus).call_deferred();
+	}
+}
+
+void ProjectDialog::_path_selected(const String &p_path) {
+	// If not already shown.
+	show_dialog();
+
+	String sp = p_path.simplify_path();
+	project_path->set_text(sp);
+	_update_path(sp);
+	callable_mp((Control *)get_ok_button(), &Control::grab_focus).call_deferred();
+}
+
+void ProjectDialog::_install_path_selected(const String &p_path) {
+	String sp = p_path.simplify_path();
+	install_path->set_text(sp);
+	_update_path(sp);
+	callable_mp((Control *)get_ok_button(), &Control::grab_focus).call_deferred();
+}
+
+void ProjectDialog::_browse_path() {
+	fdialog->set_current_dir(project_path->get_text());
+
+	if (mode == MODE_IMPORT) {
+		fdialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_ANY);
+		fdialog->clear_filters();
+		fdialog->add_filter("project.godot", vformat("%s %s", VERSION_NAME, TTR("Project")));
+		fdialog->add_filter("*.zip", TTR("ZIP File"));
+	} else {
+		fdialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_DIR);
+	}
+	fdialog->popup_file_dialog();
+}
+
+void ProjectDialog::_browse_install_path() {
+	fdialog_install->set_current_dir(install_path->get_text());
+	fdialog_install->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_DIR);
+	fdialog_install->popup_file_dialog();
+}
+
+void ProjectDialog::_create_folder() {
+	const String project_name_no_edges = project_name->get_text().strip_edges();
+	if (project_name_no_edges.is_empty() || !created_folder_path.is_empty() || project_name_no_edges.ends_with(".")) {
+		_set_message(TTR("Invalid project name."), MESSAGE_WARNING);
+		return;
+	}
+
+	Ref<DirAccess> d = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+	if (d->change_dir(project_path->get_text()) == OK) {
+		if (!d->dir_exists(project_name_no_edges)) {
+			if (d->make_dir(project_name_no_edges) == OK) {
+				d->change_dir(project_name_no_edges);
+				String dir_str = d->get_current_dir();
+				project_path->set_text(dir_str);
+				_update_path(dir_str);
+				created_folder_path = d->get_current_dir();
+				create_dir->set_disabled(true);
+			} else {
+				dialog_error->set_text(TTR("Couldn't create folder."));
+				dialog_error->popup_centered();
+			}
+		} else {
+			dialog_error->set_text(TTR("There is already a folder in this path with the specified name."));
+			dialog_error->popup_centered();
+		}
+	}
+}
+
+void ProjectDialog::_text_changed(const String &p_text) {
+	if (mode != MODE_NEW) {
+		return;
+	}
+
+	_test_path();
+
+	if (p_text.strip_edges().is_empty()) {
+		_set_message(TTR("It would be a good idea to name your project."), MESSAGE_ERROR);
+	}
+}
+
+void ProjectDialog::_nonempty_confirmation_ok_pressed() {
+	is_folder_empty = true;
+	ok_pressed();
+}
+
+void ProjectDialog::_renderer_selected() {
+	ERR_FAIL_NULL(renderer_button_group->get_pressed_button());
+
+	String renderer_type = renderer_button_group->get_pressed_button()->get_meta(SNAME("rendering_method"));
+
+	if (renderer_type == "forward_plus") {
+		renderer_info->set_text(
+				String::utf8("•  ") + TTR("Supports desktop platforms only.") +
+				String::utf8("\n•  ") + TTR("Advanced 3D graphics available.") +
+				String::utf8("\n•  ") + TTR("Can scale to large complex scenes.") +
+				String::utf8("\n•  ") + TTR("Uses RenderingDevice backend.") +
+				String::utf8("\n•  ") + TTR("Slower rendering of simple scenes."));
+	} else if (renderer_type == "mobile") {
+		renderer_info->set_text(
+				String::utf8("•  ") + TTR("Supports desktop + mobile platforms.") +
+				String::utf8("\n•  ") + TTR("Less advanced 3D graphics.") +
+				String::utf8("\n•  ") + TTR("Less scalable for complex scenes.") +
+				String::utf8("\n•  ") + TTR("Uses RenderingDevice backend.") +
+				String::utf8("\n•  ") + TTR("Fast rendering of simple scenes."));
+	} else if (renderer_type == "gl_compatibility") {
+		renderer_info->set_text(
+				String::utf8("•  ") + TTR("Supports desktop, mobile + web platforms.") +
+				String::utf8("\n•  ") + TTR("Least advanced 3D graphics (currently work-in-progress).") +
+				String::utf8("\n•  ") + TTR("Intended for low-end/older devices.") +
+				String::utf8("\n•  ") + TTR("Uses OpenGL 3 backend (OpenGL 3.3/ES 3.0/WebGL2).") +
+				String::utf8("\n•  ") + TTR("Fastest rendering of simple scenes."));
+	} else {
+		WARN_PRINT("Unknown renderer type. Please report this as a bug on GitHub.");
+	}
+}
+
+void ProjectDialog::_remove_created_folder() {
+	if (!created_folder_path.is_empty()) {
+		Ref<DirAccess> d = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+		d->remove(created_folder_path);
+
+		create_dir->set_disabled(false);
+		created_folder_path = "";
+	}
+}
+
+void ProjectDialog::ok_pressed() {
+	String dir = project_path->get_text();
+
+	if (mode == MODE_RENAME) {
+		String dir2 = _test_path();
+		if (dir2.is_empty()) {
+			_set_message(TTR("Invalid project path (changed anything?)."), MESSAGE_ERROR);
+			return;
+		}
+
+		// Load project.godot as ConfigFile to set the new name.
+		ConfigFile cfg;
+		String project_godot = dir2.path_join("project.godot");
+		Error err = cfg.load(project_godot);
+		if (err != OK) {
+			_set_message(vformat(TTR("Couldn't load project at '%s' (error %d). It may be missing or corrupted."), project_godot, err), MESSAGE_ERROR);
+		} else {
+			cfg.set_value("application", "config/name", project_name->get_text().strip_edges());
+			err = cfg.save(project_godot);
+			if (err != OK) {
+				_set_message(vformat(TTR("Couldn't save project at '%s' (error %d)."), project_godot, err), MESSAGE_ERROR);
+			}
+		}
+
+		hide();
+		emit_signal(SNAME("projects_updated"));
+
+	} else {
+		if (mode == MODE_IMPORT) {
+			if (project_path->get_text().ends_with(".zip")) {
+				mode = MODE_INSTALL;
+				ok_pressed();
+
+				return;
+			}
+
+		} else {
+			if (mode == MODE_NEW) {
+				// Before we create a project, check that the target folder is empty.
+				// If not, we need to ask the user if they're sure they want to do this.
+				if (!is_folder_empty) {
+					ConfirmationDialog *cd = memnew(ConfirmationDialog);
+					cd->set_title(TTR("Warning: This folder is not empty"));
+					cd->set_text(TTR("You are about to create a Godot project in a non-empty folder.\nThe entire contents of this folder will be imported as project resources!\n\nAre you sure you wish to continue?"));
+					cd->get_ok_button()->connect("pressed", callable_mp(this, &ProjectDialog::_nonempty_confirmation_ok_pressed));
+					get_parent()->add_child(cd);
+					cd->popup_centered();
+					cd->grab_focus();
+					return;
+				}
+				PackedStringArray project_features = ProjectSettings::get_required_features();
+				ProjectSettings::CustomMap initial_settings;
+
+				// Be sure to change this code if/when renderers are changed.
+				// Default values are "forward_plus" for the main setting, "mobile" for the mobile override,
+				// and "gl_compatibility" for the web override.
+				String renderer_type = renderer_button_group->get_pressed_button()->get_meta(SNAME("rendering_method"));
+				initial_settings["rendering/renderer/rendering_method"] = renderer_type;
+
+				EditorSettings::get_singleton()->set("project_manager/default_renderer", renderer_type);
+				EditorSettings::get_singleton()->save();
+
+				if (renderer_type == "forward_plus") {
+					project_features.push_back("Forward Plus");
+				} else if (renderer_type == "mobile") {
+					project_features.push_back("Mobile");
+				} else if (renderer_type == "gl_compatibility") {
+					project_features.push_back("GL Compatibility");
+					// Also change the default rendering method for the mobile override.
+					initial_settings["rendering/renderer/rendering_method.mobile"] = "gl_compatibility";
+				} else {
+					WARN_PRINT("Unknown renderer type. Please report this as a bug on GitHub.");
+				}
+
+				project_features.sort();
+				initial_settings["application/config/features"] = project_features;
+				initial_settings["application/config/name"] = project_name->get_text().strip_edges();
+				initial_settings["application/config/icon"] = "res://icon.svg";
+
+				if (ProjectSettings::get_singleton()->save_custom(dir.path_join("project.godot"), initial_settings, Vector<String>(), false) != OK) {
+					_set_message(TTR("Couldn't create project.godot in project path."), MESSAGE_ERROR);
+				} else {
+					// Store default project icon in SVG format.
+					Error err;
+					Ref<FileAccess> fa_icon = FileAccess::open(dir.path_join("icon.svg"), FileAccess::WRITE, &err);
+					fa_icon->store_string(get_default_project_icon());
+
+					if (err != OK) {
+						_set_message(TTR("Couldn't create icon.svg in project path."), MESSAGE_ERROR);
+					}
+
+					EditorVCSInterface::create_vcs_metadata_files(EditorVCSInterface::VCSMetadata(vcs_metadata_selection->get_selected()), dir);
+				}
+			} else if (mode == MODE_INSTALL) {
+				if (project_path->get_text().ends_with(".zip")) {
+					dir = install_path->get_text();
+					zip_path = project_path->get_text();
+				}
+
+				Ref<FileAccess> io_fa;
+				zlib_filefunc_def io = zipio_create_io(&io_fa);
+
+				unzFile pkg = unzOpen2(zip_path.utf8().get_data(), &io);
+				if (!pkg) {
+					dialog_error->set_text(TTR("Error opening package file, not in ZIP format."));
+					dialog_error->popup_centered();
+					return;
+				}
+
+				// Find the zip_root
+				String zip_root;
+				int ret = unzGoToFirstFile(pkg);
+				while (ret == UNZ_OK) {
+					unz_file_info info;
+					char fname[16384];
+					unzGetCurrentFileInfo(pkg, &info, fname, 16384, nullptr, 0, nullptr, 0);
+
+					String name = String::utf8(fname);
+					if (name.ends_with("project.godot")) {
+						zip_root = name.substr(0, name.rfind("project.godot"));
+						break;
+					}
+
+					ret = unzGoToNextFile(pkg);
+				}
+
+				ret = unzGoToFirstFile(pkg);
+
+				Vector<String> failed_files;
+
+				while (ret == UNZ_OK) {
+					//get filename
+					unz_file_info info;
+					char fname[16384];
+					ret = unzGetCurrentFileInfo(pkg, &info, fname, 16384, nullptr, 0, nullptr, 0);
+					if (ret != UNZ_OK) {
+						break;
+					}
+
+					String path = String::utf8(fname);
+
+					if (path.is_empty() || path == zip_root || !zip_root.is_subsequence_of(path)) {
+						//
+					} else if (path.ends_with("/")) { // a dir
+						path = path.substr(0, path.length() - 1);
+						String rel_path = path.substr(zip_root.length());
+
+						Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+						da->make_dir(dir.path_join(rel_path));
+					} else {
+						Vector<uint8_t> uncomp_data;
+						uncomp_data.resize(info.uncompressed_size);
+						String rel_path = path.substr(zip_root.length());
+
+						//read
+						unzOpenCurrentFile(pkg);
+						ret = unzReadCurrentFile(pkg, uncomp_data.ptrw(), uncomp_data.size());
+						ERR_BREAK_MSG(ret < 0, vformat("An error occurred while attempting to read from file: %s. This file will not be used.", rel_path));
+						unzCloseCurrentFile(pkg);
+
+						Ref<FileAccess> f = FileAccess::open(dir.path_join(rel_path), FileAccess::WRITE);
+						if (f.is_valid()) {
+							f->store_buffer(uncomp_data.ptr(), uncomp_data.size());
+						} else {
+							failed_files.push_back(rel_path);
+						}
+					}
+
+					ret = unzGoToNextFile(pkg);
+				}
+
+				unzClose(pkg);
+
+				if (failed_files.size()) {
+					String err_msg = TTR("The following files failed extraction from package:") + "\n\n";
+					for (int i = 0; i < failed_files.size(); i++) {
+						if (i > 15) {
+							err_msg += "\nAnd " + itos(failed_files.size() - i) + " more files.";
+							break;
+						}
+						err_msg += failed_files[i] + "\n";
+					}
+
+					dialog_error->set_text(err_msg);
+					dialog_error->popup_centered();
+
+				} else if (!project_path->get_text().ends_with(".zip")) {
+					dialog_error->set_text(TTR("Package installed successfully!"));
+					dialog_error->popup_centered();
+				}
+			}
+		}
+
+		dir = dir.replace("\\", "/");
+		if (dir.ends_with("/")) {
+			dir = dir.substr(0, dir.length() - 1);
+		}
+
+		hide();
+		emit_signal(SNAME("project_created"), dir);
+	}
+}
+
+void ProjectDialog::cancel_pressed() {
+	_remove_created_folder();
+
+	project_path->clear();
+	_update_path("");
+	project_name->clear();
+	_text_changed("");
+
+	if (status_rect->get_texture() == get_editor_theme_icon(SNAME("StatusError"))) {
+		msg->show();
+	}
+
+	if (install_status_rect->get_texture() == get_editor_theme_icon(SNAME("StatusError"))) {
+		msg->show();
+	}
+}
+
+void ProjectDialog::set_zip_path(const String &p_path) {
+	zip_path = p_path;
+}
+
+void ProjectDialog::set_zip_title(const String &p_title) {
+	zip_title = p_title;
+}
+
+void ProjectDialog::set_mode(Mode p_mode) {
+	mode = p_mode;
+}
+
+void ProjectDialog::set_project_path(const String &p_path) {
+	project_path->set_text(p_path);
+}
+
+void ProjectDialog::ask_for_path_and_show() {
+	// Workaround: for the file selection dialog content to be rendered we need to show its parent dialog.
+	show_dialog();
+	_set_message("");
+
+	_browse_path();
+}
+
+void ProjectDialog::show_dialog() {
+	if (mode == MODE_RENAME) {
+		project_path->set_editable(false);
+		browse->hide();
+		install_browse->hide();
+
+		set_title(TTR("Rename Project"));
+		set_ok_button_text(TTR("Rename"));
+		name_container->show();
+		status_rect->hide();
+		msg->hide();
+		install_path_container->hide();
+		install_status_rect->hide();
+		renderer_container->hide();
+		default_files_container->hide();
+		get_ok_button()->set_disabled(false);
+
+		// Fetch current name from project.godot to prefill the text input.
+		ConfigFile cfg;
+		String project_godot = project_path->get_text().path_join("project.godot");
+		Error err = cfg.load(project_godot);
+		if (err != OK) {
+			_set_message(vformat(TTR("Couldn't load project at '%s' (error %d). It may be missing or corrupted."), project_godot, err), MESSAGE_ERROR);
+			status_rect->show();
+			msg->show();
+			get_ok_button()->set_disabled(true);
+		} else {
+			String cur_name = cfg.get_value("application", "config/name", "");
+			project_name->set_text(cur_name);
+			_text_changed(cur_name);
+		}
+
+		callable_mp((Control *)project_name, &Control::grab_focus).call_deferred();
+
+		create_dir->hide();
+
+	} else {
+		fav_dir = EDITOR_GET("filesystem/directories/default_project_path");
+		if (!fav_dir.is_empty()) {
+			project_path->set_text(fav_dir);
+			fdialog->set_current_dir(fav_dir);
+		} else {
+			Ref<DirAccess> d = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+			project_path->set_text(d->get_current_dir());
+			fdialog->set_current_dir(d->get_current_dir());
+		}
+
+		if (project_name->get_text().is_empty()) {
+			String proj = TTR("New Game Project");
+			project_name->set_text(proj);
+			_text_changed(proj);
+		}
+
+		project_path->set_editable(true);
+		browse->set_disabled(false);
+		browse->show();
+		install_browse->set_disabled(false);
+		install_browse->show();
+		create_dir->show();
+		status_rect->show();
+		install_status_rect->show();
+		msg->show();
+
+		if (mode == MODE_IMPORT) {
+			set_title(TTR("Import Existing Project"));
+			set_ok_button_text(TTR("Import & Edit"));
+			name_container->hide();
+			install_path_container->hide();
+			renderer_container->hide();
+			default_files_container->hide();
+			project_path->grab_focus();
+
+		} else if (mode == MODE_NEW) {
+			set_title(TTR("Create New Project"));
+			set_ok_button_text(TTR("Create & Edit"));
+			name_container->show();
+			install_path_container->hide();
+			renderer_container->show();
+			default_files_container->show();
+			callable_mp((Control *)project_name, &Control::grab_focus).call_deferred();
+			callable_mp(project_name, &LineEdit::select_all).call_deferred();
+
+		} else if (mode == MODE_INSTALL) {
+			set_title(TTR("Install Project:") + " " + zip_title);
+			set_ok_button_text(TTR("Install & Edit"));
+			project_name->set_text(zip_title);
+			name_container->show();
+			install_path_container->hide();
+			renderer_container->hide();
+			default_files_container->hide();
+			project_path->grab_focus();
+		}
+
+		_test_path();
+	}
+
+	popup_centered(Size2(500, 0) * EDSCALE);
+}
+
+void ProjectDialog::_notification(int p_what) {
+	switch (p_what) {
+		case NOTIFICATION_WM_CLOSE_REQUEST: {
+			_remove_created_folder();
+		} break;
+	}
+}
+
+void ProjectDialog::_bind_methods() {
+	ADD_SIGNAL(MethodInfo("project_created"));
+	ADD_SIGNAL(MethodInfo("projects_updated"));
+}
+
+ProjectDialog::ProjectDialog() {
+	VBoxContainer *vb = memnew(VBoxContainer);
+	add_child(vb);
+
+	name_container = memnew(VBoxContainer);
+	vb->add_child(name_container);
+
+	Label *l = memnew(Label);
+	l->set_text(TTR("Project Name:"));
+	name_container->add_child(l);
+
+	HBoxContainer *pnhb = memnew(HBoxContainer);
+	name_container->add_child(pnhb);
+
+	project_name = memnew(LineEdit);
+	project_name->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	pnhb->add_child(project_name);
+
+	create_dir = memnew(Button);
+	pnhb->add_child(create_dir);
+	create_dir->set_text(TTR("Create Folder"));
+	create_dir->connect("pressed", callable_mp(this, &ProjectDialog::_create_folder));
+
+	path_container = memnew(VBoxContainer);
+	vb->add_child(path_container);
+
+	l = memnew(Label);
+	l->set_text(TTR("Project Path:"));
+	path_container->add_child(l);
+
+	HBoxContainer *pphb = memnew(HBoxContainer);
+	path_container->add_child(pphb);
+
+	project_path = memnew(LineEdit);
+	project_path->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	project_path->set_structured_text_bidi_override(TextServer::STRUCTURED_TEXT_FILE);
+	pphb->add_child(project_path);
+
+	install_path_container = memnew(VBoxContainer);
+	vb->add_child(install_path_container);
+
+	l = memnew(Label);
+	l->set_text(TTR("Project Installation Path:"));
+	install_path_container->add_child(l);
+
+	HBoxContainer *iphb = memnew(HBoxContainer);
+	install_path_container->add_child(iphb);
+
+	install_path = memnew(LineEdit);
+	install_path->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	install_path->set_structured_text_bidi_override(TextServer::STRUCTURED_TEXT_FILE);
+	iphb->add_child(install_path);
+
+	// status icon
+	status_rect = memnew(TextureRect);
+	status_rect->set_stretch_mode(TextureRect::STRETCH_KEEP_CENTERED);
+	pphb->add_child(status_rect);
+
+	browse = memnew(Button);
+	browse->set_text(TTR("Browse"));
+	browse->connect("pressed", callable_mp(this, &ProjectDialog::_browse_path));
+	pphb->add_child(browse);
+
+	// install status icon
+	install_status_rect = memnew(TextureRect);
+	install_status_rect->set_stretch_mode(TextureRect::STRETCH_KEEP_CENTERED);
+	iphb->add_child(install_status_rect);
+
+	install_browse = memnew(Button);
+	install_browse->set_text(TTR("Browse"));
+	install_browse->connect("pressed", callable_mp(this, &ProjectDialog::_browse_install_path));
+	iphb->add_child(install_browse);
+
+	msg = memnew(Label);
+	msg->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
+	msg->set_custom_minimum_size(Size2(200, 0) * EDSCALE);
+	vb->add_child(msg);
+
+	// Renderer selection.
+	renderer_container = memnew(VBoxContainer);
+	vb->add_child(renderer_container);
+	l = memnew(Label);
+	l->set_text(TTR("Renderer:"));
+	renderer_container->add_child(l);
+	HBoxContainer *rshc = memnew(HBoxContainer);
+	renderer_container->add_child(rshc);
+	renderer_button_group.instantiate();
+
+	// Left hand side, used for checkboxes to select renderer.
+	Container *rvb = memnew(VBoxContainer);
+	rshc->add_child(rvb);
+
+	String default_renderer_type = "forward_plus";
+	if (EditorSettings::get_singleton()->has_setting("project_manager/default_renderer")) {
+		default_renderer_type = EditorSettings::get_singleton()->get_setting("project_manager/default_renderer");
+	}
+
+	Button *rs_button = memnew(CheckBox);
+	rs_button->set_button_group(renderer_button_group);
+	rs_button->set_text(TTR("Forward+"));
+#if defined(WEB_ENABLED)
+	rs_button->set_disabled(true);
+#endif
+	rs_button->set_meta(SNAME("rendering_method"), "forward_plus");
+	rs_button->connect("pressed", callable_mp(this, &ProjectDialog::_renderer_selected));
+	rvb->add_child(rs_button);
+	if (default_renderer_type == "forward_plus") {
+		rs_button->set_pressed(true);
+	}
+	rs_button = memnew(CheckBox);
+	rs_button->set_button_group(renderer_button_group);
+	rs_button->set_text(TTR("Mobile"));
+#if defined(WEB_ENABLED)
+	rs_button->set_disabled(true);
+#endif
+	rs_button->set_meta(SNAME("rendering_method"), "mobile");
+	rs_button->connect("pressed", callable_mp(this, &ProjectDialog::_renderer_selected));
+	rvb->add_child(rs_button);
+	if (default_renderer_type == "mobile") {
+		rs_button->set_pressed(true);
+	}
+	rs_button = memnew(CheckBox);
+	rs_button->set_button_group(renderer_button_group);
+	rs_button->set_text(TTR("Compatibility"));
+#if !defined(GLES3_ENABLED)
+	rs_button->set_disabled(true);
+#endif
+	rs_button->set_meta(SNAME("rendering_method"), "gl_compatibility");
+	rs_button->connect("pressed", callable_mp(this, &ProjectDialog::_renderer_selected));
+	rvb->add_child(rs_button);
+#if defined(GLES3_ENABLED)
+	if (default_renderer_type == "gl_compatibility") {
+		rs_button->set_pressed(true);
+	}
+#endif
+	rshc->add_child(memnew(VSeparator));
+
+	// Right hand side, used for text explaining each choice.
+	rvb = memnew(VBoxContainer);
+	rvb->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	rshc->add_child(rvb);
+	renderer_info = memnew(Label);
+	renderer_info->set_modulate(Color(1, 1, 1, 0.7));
+	rvb->add_child(renderer_info);
+	_renderer_selected();
+
+	l = memnew(Label);
+	l->set_text(TTR("The renderer can be changed later, but scenes may need to be adjusted."));
+	// Add some extra spacing to separate it from the list above and the buttons below.
+	l->set_custom_minimum_size(Size2(0, 40) * EDSCALE);
+	l->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
+	l->set_vertical_alignment(VERTICAL_ALIGNMENT_CENTER);
+	l->set_modulate(Color(1, 1, 1, 0.7));
+	renderer_container->add_child(l);
+
+	default_files_container = memnew(HBoxContainer);
+	vb->add_child(default_files_container);
+	l = memnew(Label);
+	l->set_text(TTR("Version Control Metadata:"));
+	default_files_container->add_child(l);
+	vcs_metadata_selection = memnew(OptionButton);
+	vcs_metadata_selection->set_custom_minimum_size(Size2(100, 20));
+	vcs_metadata_selection->add_item(TTR("None"), (int)EditorVCSInterface::VCSMetadata::NONE);
+	vcs_metadata_selection->add_item(TTR("Git"), (int)EditorVCSInterface::VCSMetadata::GIT);
+	vcs_metadata_selection->select((int)EditorVCSInterface::VCSMetadata::GIT);
+	default_files_container->add_child(vcs_metadata_selection);
+	Control *spacer = memnew(Control);
+	spacer->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	default_files_container->add_child(spacer);
+
+	fdialog = memnew(EditorFileDialog);
+	fdialog->set_previews_enabled(false); //Crucial, otherwise the engine crashes.
+	fdialog->set_access(EditorFileDialog::ACCESS_FILESYSTEM);
+	fdialog_install = memnew(EditorFileDialog);
+	fdialog_install->set_previews_enabled(false); //Crucial, otherwise the engine crashes.
+	fdialog_install->set_access(EditorFileDialog::ACCESS_FILESYSTEM);
+	add_child(fdialog);
+	add_child(fdialog_install);
+
+	project_name->connect("text_changed", callable_mp(this, &ProjectDialog::_text_changed));
+	project_path->connect("text_changed", callable_mp(this, &ProjectDialog::_path_text_changed));
+	install_path->connect("text_changed", callable_mp(this, &ProjectDialog::_update_path));
+	fdialog->connect("dir_selected", callable_mp(this, &ProjectDialog::_path_selected));
+	fdialog->connect("file_selected", callable_mp(this, &ProjectDialog::_file_selected));
+	fdialog_install->connect("dir_selected", callable_mp(this, &ProjectDialog::_install_path_selected));
+	fdialog_install->connect("file_selected", callable_mp(this, &ProjectDialog::_install_path_selected));
+
+	set_hide_on_ok(false);
+
+	dialog_error = memnew(AcceptDialog);
+	add_child(dialog_error);
+}

+ 136 - 0
editor/project_manager/project_dialog.h

@@ -0,0 +1,136 @@
+/**************************************************************************/
+/*  project_dialog.h                                                      */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#ifndef PROJECT_DIALOG_H
+#define PROJECT_DIALOG_H
+
+#include "scene/gui/dialogs.h"
+
+class Button;
+class EditorFileDialog;
+class LineEdit;
+class OptionButton;
+class TextureRect;
+
+class ProjectDialog : public ConfirmationDialog {
+	GDCLASS(ProjectDialog, ConfirmationDialog);
+
+public:
+	enum Mode {
+		MODE_NEW,
+		MODE_IMPORT,
+		MODE_INSTALL,
+		MODE_RENAME,
+	};
+
+private:
+	enum MessageType {
+		MESSAGE_ERROR,
+		MESSAGE_WARNING,
+		MESSAGE_SUCCESS,
+	};
+
+	enum InputType {
+		PROJECT_PATH,
+		INSTALL_PATH,
+	};
+
+	Mode mode = MODE_NEW;
+	bool is_folder_empty = true;
+
+	Button *browse = nullptr;
+	Button *install_browse = nullptr;
+	Button *create_dir = nullptr;
+	VBoxContainer *name_container = nullptr;
+	VBoxContainer *path_container = nullptr;
+	VBoxContainer *install_path_container = nullptr;
+
+	VBoxContainer *renderer_container = nullptr;
+	Label *renderer_info = nullptr;
+	HBoxContainer *default_files_container = nullptr;
+	Ref<ButtonGroup> renderer_button_group;
+
+	Label *msg = nullptr;
+	LineEdit *project_path = nullptr;
+	LineEdit *project_name = nullptr;
+	LineEdit *install_path = nullptr;
+	TextureRect *status_rect = nullptr;
+	TextureRect *install_status_rect = nullptr;
+
+	OptionButton *vcs_metadata_selection = nullptr;
+
+	EditorFileDialog *fdialog = nullptr;
+	EditorFileDialog *fdialog_install = nullptr;
+	AcceptDialog *dialog_error = nullptr;
+
+	String zip_path;
+	String zip_title;
+	String fav_dir;
+
+	String created_folder_path;
+
+	void _set_message(const String &p_msg, MessageType p_type = MESSAGE_SUCCESS, InputType input_type = PROJECT_PATH);
+
+	String _test_path();
+	void _update_path(const String &p_path);
+	void _path_text_changed(const String &p_path);
+	void _path_selected(const String &p_path);
+	void _file_selected(const String &p_path);
+	void _install_path_selected(const String &p_path);
+
+	void _browse_path();
+	void _browse_install_path();
+	void _create_folder();
+
+	void _text_changed(const String &p_text);
+	void _nonempty_confirmation_ok_pressed();
+	void _renderer_selected();
+	void _remove_created_folder();
+
+	void ok_pressed() override;
+	void cancel_pressed() override;
+
+protected:
+	void _notification(int p_what);
+	static void _bind_methods();
+
+public:
+	void set_zip_path(const String &p_path);
+	void set_zip_title(const String &p_title);
+	void set_mode(Mode p_mode);
+	void set_project_path(const String &p_path);
+
+	void ask_for_path_and_show();
+	void show_dialog();
+
+	ProjectDialog();
+};
+
+#endif // PROJECT_DIALOG_H

+ 1074 - 0
editor/project_manager/project_list.cpp

@@ -0,0 +1,1074 @@
+/**************************************************************************/
+/*  project_list.cpp                                                      */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#include "project_list.h"
+
+#include "core/config/project_settings.h"
+#include "core/io/dir_access.h"
+#include "editor/editor_paths.h"
+#include "editor/editor_settings.h"
+#include "editor/editor_string_names.h"
+#include "editor/project_manager.h"
+#include "editor/project_manager/project_tag.h"
+#include "editor/themes/editor_scale.h"
+#include "scene/gui/button.h"
+#include "scene/gui/label.h"
+#include "scene/gui/line_edit.h"
+#include "scene/gui/texture_button.h"
+#include "scene/gui/texture_rect.h"
+#include "scene/resources/image_texture.h"
+
+void ProjectListItemControl::_notification(int p_what) {
+	switch (p_what) {
+		case NOTIFICATION_THEME_CHANGED: {
+			if (icon_needs_reload) {
+				// The project icon may not be loaded by the time the control is displayed,
+				// so use a loading placeholder.
+				project_icon->set_texture(get_editor_theme_icon(SNAME("ProjectIconLoading")));
+			}
+
+			project_title->begin_bulk_theme_override();
+			project_title->add_theme_font_override("font", get_theme_font(SNAME("title"), EditorStringName(EditorFonts)));
+			project_title->add_theme_font_size_override("font_size", get_theme_font_size(SNAME("title_size"), EditorStringName(EditorFonts)));
+			project_title->add_theme_color_override("font_color", get_theme_color(SNAME("font_color"), SNAME("Tree")));
+			project_title->end_bulk_theme_override();
+
+			project_path->add_theme_color_override("font_color", get_theme_color(SNAME("font_color"), SNAME("Tree")));
+			project_unsupported_features->set_texture(get_editor_theme_icon(SNAME("NodeWarning")));
+
+			favorite_button->set_texture_normal(get_editor_theme_icon(SNAME("Favorites")));
+			if (project_is_missing) {
+				explore_button->set_icon(get_editor_theme_icon(SNAME("FileBroken")));
+			} else {
+				explore_button->set_icon(get_editor_theme_icon(SNAME("Load")));
+			}
+		} break;
+
+		case NOTIFICATION_MOUSE_ENTER: {
+			is_hovering = true;
+			queue_redraw();
+		} break;
+
+		case NOTIFICATION_MOUSE_EXIT: {
+			is_hovering = false;
+			queue_redraw();
+		} break;
+
+		case NOTIFICATION_DRAW: {
+			if (is_selected) {
+				draw_style_box(get_theme_stylebox(SNAME("selected"), SNAME("Tree")), Rect2(Point2(), get_size()));
+			}
+			if (is_hovering) {
+				draw_style_box(get_theme_stylebox(SNAME("hover"), SNAME("Tree")), Rect2(Point2(), get_size()));
+			}
+
+			draw_line(Point2(0, get_size().y + 1), Point2(get_size().x, get_size().y + 1), get_theme_color(SNAME("guide_color"), SNAME("Tree")));
+		} break;
+	}
+}
+
+void ProjectListItemControl::_favorite_button_pressed() {
+	emit_signal(SNAME("favorite_pressed"));
+}
+
+void ProjectListItemControl::_explore_button_pressed() {
+	emit_signal(SNAME("explore_pressed"));
+}
+
+void ProjectListItemControl::set_project_title(const String &p_title) {
+	project_title->set_text(p_title);
+}
+
+void ProjectListItemControl::set_project_path(const String &p_path) {
+	project_path->set_text(p_path);
+}
+
+void ProjectListItemControl::set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list) {
+	for (const String &tag : p_tags) {
+		ProjectTag *tag_control = memnew(ProjectTag(tag));
+		tag_container->add_child(tag_control);
+		tag_control->connect_button_to(callable_mp(p_parent_list, &ProjectList::add_search_tag).bind(tag));
+	}
+}
+
+void ProjectListItemControl::set_project_icon(const Ref<Texture2D> &p_icon) {
+	icon_needs_reload = false;
+
+	// The default project icon is 128×128 to look crisp on hiDPI displays,
+	// but we want the actual displayed size to be 64×64 on loDPI displays.
+	project_icon->set_expand_mode(TextureRect::EXPAND_IGNORE_SIZE);
+	project_icon->set_custom_minimum_size(Size2(64, 64) * EDSCALE);
+	project_icon->set_stretch_mode(TextureRect::STRETCH_KEEP_ASPECT_CENTERED);
+
+	project_icon->set_texture(p_icon);
+}
+
+void ProjectListItemControl::set_unsupported_features(PackedStringArray p_features) {
+	if (p_features.size() > 0) {
+		String tooltip_text = "";
+		for (int i = 0; i < p_features.size(); i++) {
+			if (ProjectList::project_feature_looks_like_version(p_features[i])) {
+				tooltip_text += TTR("This project was last edited in a different Godot version: ") + p_features[i] + "\n";
+				p_features.remove_at(i);
+				i--;
+			}
+		}
+		if (p_features.size() > 0) {
+			String unsupported_features_str = String(", ").join(p_features);
+			tooltip_text += TTR("This project uses features unsupported by the current build:") + "\n" + unsupported_features_str;
+		}
+		project_unsupported_features->set_tooltip_text(tooltip_text);
+		project_unsupported_features->show();
+	} else {
+		project_unsupported_features->hide();
+	}
+}
+
+bool ProjectListItemControl::should_load_project_icon() const {
+	return icon_needs_reload;
+}
+
+void ProjectListItemControl::set_selected(bool p_selected) {
+	is_selected = p_selected;
+	queue_redraw();
+}
+
+void ProjectListItemControl::set_is_favorite(bool p_favorite) {
+	favorite_button->set_modulate(p_favorite ? Color(1, 1, 1, 1) : Color(1, 1, 1, 0.2));
+}
+
+void ProjectListItemControl::set_is_missing(bool p_missing) {
+	if (project_is_missing == p_missing) {
+		return;
+	}
+	project_is_missing = p_missing;
+
+	if (project_is_missing) {
+		project_icon->set_modulate(Color(1, 1, 1, 0.5));
+
+		explore_button->set_icon(get_editor_theme_icon(SNAME("FileBroken")));
+		explore_button->set_tooltip_text(TTR("Error: Project is missing on the filesystem."));
+	} else {
+		project_icon->set_modulate(Color(1, 1, 1, 1.0));
+
+		explore_button->set_icon(get_editor_theme_icon(SNAME("Load")));
+#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)
+		explore_button->set_tooltip_text(TTR("Show in File Manager"));
+#else
+		// Opening the system file manager is not supported on the Android and web editors.
+		explore_button->hide();
+#endif
+	}
+}
+
+void ProjectListItemControl::set_is_grayed(bool p_grayed) {
+	if (p_grayed) {
+		main_vbox->set_modulate(Color(1, 1, 1, 0.5));
+		// Don't make the icon less prominent if the parent is already grayed out.
+		explore_button->set_modulate(Color(1, 1, 1, 1.0));
+	} else {
+		main_vbox->set_modulate(Color(1, 1, 1, 1.0));
+		explore_button->set_modulate(Color(1, 1, 1, 0.5));
+	}
+}
+
+void ProjectListItemControl::_bind_methods() {
+	ADD_SIGNAL(MethodInfo("favorite_pressed"));
+	ADD_SIGNAL(MethodInfo("explore_pressed"));
+}
+
+ProjectListItemControl::ProjectListItemControl() {
+	set_focus_mode(FocusMode::FOCUS_ALL);
+
+	VBoxContainer *favorite_box = memnew(VBoxContainer);
+	favorite_box->set_alignment(BoxContainer::ALIGNMENT_CENTER);
+	add_child(favorite_box);
+
+	favorite_button = memnew(TextureButton);
+	favorite_button->set_name("FavoriteButton");
+	// This makes the project's "hover" style display correctly when hovering the favorite icon.
+	favorite_button->set_mouse_filter(MOUSE_FILTER_PASS);
+	favorite_box->add_child(favorite_button);
+	favorite_button->connect("pressed", callable_mp(this, &ProjectListItemControl::_favorite_button_pressed));
+
+	project_icon = memnew(TextureRect);
+	project_icon->set_name("ProjectIcon");
+	project_icon->set_v_size_flags(SIZE_SHRINK_CENTER);
+	add_child(project_icon);
+
+	main_vbox = memnew(VBoxContainer);
+	main_vbox->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	add_child(main_vbox);
+
+	Control *ec = memnew(Control);
+	ec->set_custom_minimum_size(Size2(0, 1));
+	ec->set_mouse_filter(MOUSE_FILTER_PASS);
+	main_vbox->add_child(ec);
+
+	// Top half, title, tags and unsupported features labels.
+	{
+		HBoxContainer *title_hb = memnew(HBoxContainer);
+		main_vbox->add_child(title_hb);
+
+		project_title = memnew(Label);
+		project_title->set_auto_translate(false);
+		project_title->set_name("ProjectName");
+		project_title->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+		project_title->set_clip_text(true);
+		title_hb->add_child(project_title);
+
+		tag_container = memnew(HBoxContainer);
+		title_hb->add_child(tag_container);
+
+		Control *spacer = memnew(Control);
+		spacer->set_custom_minimum_size(Size2(10, 10));
+		title_hb->add_child(spacer);
+	}
+
+	// Bottom half, containing the path and view folder button.
+	{
+		HBoxContainer *path_hb = memnew(HBoxContainer);
+		path_hb->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+		main_vbox->add_child(path_hb);
+
+		explore_button = memnew(Button);
+		explore_button->set_name("ExploreButton");
+		explore_button->set_flat(true);
+		path_hb->add_child(explore_button);
+		explore_button->connect("pressed", callable_mp(this, &ProjectListItemControl::_explore_button_pressed));
+
+		project_path = memnew(Label);
+		project_path->set_name("ProjectPath");
+		project_path->set_structured_text_bidi_override(TextServer::STRUCTURED_TEXT_FILE);
+		project_path->set_clip_text(true);
+		project_path->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+		project_path->set_modulate(Color(1, 1, 1, 0.5));
+		path_hb->add_child(project_path);
+
+		project_unsupported_features = memnew(TextureRect);
+		project_unsupported_features->set_name("ProjectUnsupportedFeatures");
+		project_unsupported_features->set_stretch_mode(TextureRect::STRETCH_KEEP_CENTERED);
+		path_hb->add_child(project_unsupported_features);
+		project_unsupported_features->hide();
+
+		Control *spacer = memnew(Control);
+		spacer->set_custom_minimum_size(Size2(10, 10));
+		path_hb->add_child(spacer);
+	}
+}
+
+struct ProjectListComparator {
+	ProjectList::FilterOption order_option = ProjectList::FilterOption::EDIT_DATE;
+
+	// operator<
+	_FORCE_INLINE_ bool operator()(const ProjectList::Item &a, const ProjectList::Item &b) const {
+		if (a.favorite && !b.favorite) {
+			return true;
+		}
+		if (b.favorite && !a.favorite) {
+			return false;
+		}
+		switch (order_option) {
+			case ProjectList::PATH:
+				return a.path < b.path;
+			case ProjectList::EDIT_DATE:
+				return a.last_edited > b.last_edited;
+			case ProjectList::TAGS:
+				return a.tag_sort_string < b.tag_sort_string;
+			default:
+				return a.project_name < b.project_name;
+		}
+	}
+};
+
+const char *ProjectList::SIGNAL_LIST_CHANGED = "list_changed";
+const char *ProjectList::SIGNAL_SELECTION_CHANGED = "selection_changed";
+const char *ProjectList::SIGNAL_PROJECT_ASK_OPEN = "project_ask_open";
+
+// Helpers.
+
+bool ProjectList::project_feature_looks_like_version(const String &p_feature) {
+	return p_feature.contains(".") && p_feature.substr(0, 3).is_numeric();
+}
+
+// Notifications.
+
+void ProjectList::_notification(int p_what) {
+	switch (p_what) {
+		case NOTIFICATION_PROCESS: {
+			// Load icons as a coroutine to speed up launch when you have hundreds of projects
+			if (_icon_load_index < _projects.size()) {
+				Item &item = _projects.write[_icon_load_index];
+				if (item.control->should_load_project_icon()) {
+					_load_project_icon(_icon_load_index);
+				}
+				_icon_load_index++;
+
+			} else {
+				set_process(false);
+			}
+		} break;
+	}
+}
+
+// Initialization & loading.
+
+void ProjectList::_migrate_config() {
+	// Proposal #1637 moved the project list from editor settings to a separate config file
+	// If the new config file doesn't exist, populate it from EditorSettings
+	if (FileAccess::exists(_config_path)) {
+		return;
+	}
+
+	List<PropertyInfo> properties;
+	EditorSettings::get_singleton()->get_property_list(&properties);
+
+	for (const PropertyInfo &E : properties) {
+		// This is actually something like "projects/C:::Documents::Godot::Projects::MyGame"
+		String property_key = E.name;
+		if (!property_key.begins_with("projects/")) {
+			continue;
+		}
+
+		String path = EDITOR_GET(property_key);
+		print_line("Migrating legacy project '" + path + "'.");
+
+		String favoriteKey = "favorite_projects/" + property_key.get_slice("/", 1);
+		bool favorite = EditorSettings::get_singleton()->has_setting(favoriteKey);
+		add_project(path, favorite);
+		if (favorite) {
+			EditorSettings::get_singleton()->erase(favoriteKey);
+		}
+		EditorSettings::get_singleton()->erase(property_key);
+	}
+
+	save_config();
+}
+
+void ProjectList::save_config() {
+	_config.save(_config_path);
+}
+
+// Load project data from p_property_key and return it in a ProjectList::Item.
+// p_favorite is passed directly into the Item.
+ProjectList::Item ProjectList::load_project_data(const String &p_path, bool p_favorite) {
+	String conf = p_path.path_join("project.godot");
+	bool grayed = false;
+	bool missing = false;
+
+	Ref<ConfigFile> cf = memnew(ConfigFile);
+	Error cf_err = cf->load(conf);
+
+	int config_version = 0;
+	String project_name = TTR("Unnamed Project");
+	if (cf_err == OK) {
+		String cf_project_name = cf->get_value("application", "config/name", "");
+		if (!cf_project_name.is_empty()) {
+			project_name = cf_project_name.xml_unescape();
+		}
+		config_version = (int)cf->get_value("", "config_version", 0);
+	}
+
+	if (config_version > ProjectSettings::CONFIG_VERSION) {
+		// Comes from an incompatible (more recent) Godot version, gray it out.
+		grayed = true;
+	}
+
+	const String description = cf->get_value("application", "config/description", "");
+	const PackedStringArray tags = cf->get_value("application", "config/tags", PackedStringArray());
+	const String icon = cf->get_value("application", "config/icon", "");
+	const String main_scene = cf->get_value("application", "run/main_scene", "");
+
+	PackedStringArray project_features = cf->get_value("application", "config/features", PackedStringArray());
+	PackedStringArray unsupported_features = ProjectSettings::get_unsupported_features(project_features);
+
+	uint64_t last_edited = 0;
+	if (cf_err == OK) {
+		// The modification date marks the date the project was last edited.
+		// This is because the `project.godot` file will always be modified
+		// when editing a project (but not when running it).
+		last_edited = FileAccess::get_modified_time(conf);
+
+		String fscache = p_path.path_join(".fscache");
+		if (FileAccess::exists(fscache)) {
+			uint64_t cache_modified = FileAccess::get_modified_time(fscache);
+			if (cache_modified > last_edited) {
+				last_edited = cache_modified;
+			}
+		}
+	} else {
+		grayed = true;
+		missing = true;
+		print_line("Project is missing: " + conf);
+	}
+
+	for (const String &tag : tags) {
+		ProjectManager::get_singleton()->add_new_tag(tag);
+	}
+
+	return Item(project_name, description, tags, p_path, icon, main_scene, unsupported_features, last_edited, p_favorite, grayed, missing, config_version);
+}
+
+void ProjectList::_update_icons_async() {
+	_icon_load_index = 0;
+	set_process(true);
+}
+
+void ProjectList::_load_project_icon(int p_index) {
+	Item &item = _projects.write[p_index];
+
+	Ref<Texture2D> default_icon = get_editor_theme_icon(SNAME("DefaultProjectIcon"));
+	Ref<Texture2D> icon;
+	if (!item.icon.is_empty()) {
+		Ref<Image> img;
+		img.instantiate();
+		Error err = img->load(item.icon.replace_first("res://", item.path + "/"));
+		if (err == OK) {
+			img->resize(default_icon->get_width(), default_icon->get_height(), Image::INTERPOLATE_LANCZOS);
+			icon = ImageTexture::create_from_image(img);
+		}
+	}
+	if (icon.is_null()) {
+		icon = default_icon;
+	}
+
+	item.control->set_project_icon(icon);
+}
+
+// Project list updates.
+
+void ProjectList::update_project_list() {
+	// This is a full, hard reload of the list. Don't call this unless really required, it's expensive.
+	// If you have 150 projects, it may read through 150 files on your disk at once + load 150 icons.
+	// FIXME: Does it really have to be a full, hard reload? Runtime updates should be made much cheaper.
+
+	// Clear whole list
+	for (int i = 0; i < _projects.size(); ++i) {
+		Item &project = _projects.write[i];
+		CRASH_COND(project.control == nullptr);
+		memdelete(project.control); // Why not queue_free()?
+	}
+	_projects.clear();
+	_last_clicked = "";
+	_selected_project_paths.clear();
+
+	List<String> sections;
+	_config.load(_config_path);
+	_config.get_sections(&sections);
+
+	for (const String &path : sections) {
+		bool favorite = _config.get_value(path, "favorite", false);
+		_projects.push_back(load_project_data(path, favorite));
+	}
+
+	// Create controls
+	for (int i = 0; i < _projects.size(); ++i) {
+		_create_project_item_control(i);
+	}
+
+	sort_projects();
+	_update_icons_async();
+	update_dock_menu();
+
+	set_v_scroll(0);
+	emit_signal(SNAME(SIGNAL_LIST_CHANGED));
+}
+
+void ProjectList::sort_projects() {
+	SortArray<Item, ProjectListComparator> sorter;
+	sorter.compare.order_option = _order_option;
+	sorter.sort(_projects.ptrw(), _projects.size());
+
+	String search_term;
+	PackedStringArray tags;
+
+	if (!_search_term.is_empty()) {
+		PackedStringArray search_parts = _search_term.split(" ");
+		if (search_parts.size() > 1 || search_parts[0].begins_with("tag:")) {
+			PackedStringArray remaining;
+			for (const String &part : search_parts) {
+				if (part.begins_with("tag:")) {
+					tags.push_back(part.get_slice(":", 1));
+				} else {
+					remaining.append(part);
+				}
+			}
+			search_term = String(" ").join(remaining); // Search term without tags.
+		} else {
+			search_term = _search_term;
+		}
+	}
+
+	for (int i = 0; i < _projects.size(); ++i) {
+		Item &item = _projects.write[i];
+
+		bool item_visible = true;
+		if (!_search_term.is_empty()) {
+			String search_path;
+			if (search_term.contains("/")) {
+				// Search path will match the whole path
+				search_path = item.path;
+			} else {
+				// Search path will only match the last path component to make searching more strict
+				search_path = item.path.get_file();
+			}
+
+			bool missing_tags = false;
+			for (const String &tag : tags) {
+				if (!item.tags.has(tag)) {
+					missing_tags = true;
+					break;
+				}
+			}
+
+			// When searching, display projects whose name or path contain the search term and whose tags match the searched tags.
+			item_visible = !missing_tags && (search_term.is_empty() || item.project_name.findn(search_term) != -1 || search_path.findn(search_term) != -1);
+		}
+
+		item.control->set_visible(item_visible);
+	}
+
+	for (int i = 0; i < _projects.size(); ++i) {
+		Item &item = _projects.write[i];
+		item.control->get_parent()->move_child(item.control, i);
+	}
+
+	// Rewind the coroutine because order of projects changed
+	_update_icons_async();
+	update_dock_menu();
+}
+
+int ProjectList::get_project_count() const {
+	return _projects.size();
+}
+
+void ProjectList::find_projects(const String &p_path) {
+	PackedStringArray paths = { p_path };
+	find_projects_multiple(paths);
+}
+
+void ProjectList::find_projects_multiple(const PackedStringArray &p_paths) {
+	List<String> projects;
+
+	for (int i = 0; i < p_paths.size(); i++) {
+		const String &base_path = p_paths.get(i);
+		print_verbose(vformat("Scanning for projects in \"%s\".", base_path));
+
+		_scan_folder_recursive(base_path, &projects);
+		print_verbose(vformat("Found %d project(s).", projects.size()));
+	}
+
+	for (const String &E : projects) {
+		add_project(E, false);
+	}
+
+	save_config();
+	update_project_list();
+}
+
+void ProjectList::_scan_folder_recursive(const String &p_path, List<String> *r_projects) {
+	Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+	Error error = da->change_dir(p_path);
+	ERR_FAIL_COND_MSG(error != OK, vformat("Failed to open the path \"%s\" for scanning (code %d).", p_path, error));
+
+	da->list_dir_begin();
+	String n = da->get_next();
+	while (!n.is_empty()) {
+		if (da->current_is_dir() && n[0] != '.') {
+			_scan_folder_recursive(da->get_current_dir().path_join(n), r_projects);
+		} else if (n == "project.godot") {
+			r_projects->push_back(da->get_current_dir());
+		}
+		n = da->get_next();
+	}
+	da->list_dir_end();
+}
+
+// Project list items.
+
+void ProjectList::add_project(const String &dir_path, bool favorite) {
+	if (!_config.has_section(dir_path)) {
+		_config.set_value(dir_path, "favorite", favorite);
+	}
+}
+
+void ProjectList::set_project_version(const String &p_project_path, int p_version) {
+	for (ProjectList::Item &E : _projects) {
+		if (E.path == p_project_path) {
+			E.version = p_version;
+			break;
+		}
+	}
+}
+
+int ProjectList::refresh_project(const String &dir_path) {
+	// Reloads information about a specific project.
+	// If it wasn't loaded and should be in the list, it is added (i.e new project).
+	// If it isn't in the list anymore, it is removed.
+	// If it is in the list but doesn't exist anymore, it is marked as missing.
+
+	bool should_be_in_list = _config.has_section(dir_path);
+	bool is_favourite = _config.get_value(dir_path, "favorite", false);
+
+	bool was_selected = _selected_project_paths.has(dir_path);
+
+	// Remove item in any case
+	for (int i = 0; i < _projects.size(); ++i) {
+		const Item &existing_item = _projects[i];
+		if (existing_item.path == dir_path) {
+			_remove_project(i, false);
+			break;
+		}
+	}
+
+	int index = -1;
+	if (should_be_in_list) {
+		// Recreate it with updated info
+
+		Item item = load_project_data(dir_path, is_favourite);
+
+		_projects.push_back(item);
+		_create_project_item_control(_projects.size() - 1);
+
+		sort_projects();
+
+		for (int i = 0; i < _projects.size(); ++i) {
+			if (_projects[i].path == dir_path) {
+				if (was_selected) {
+					select_project(i);
+					ensure_project_visible(i);
+				}
+				_load_project_icon(i);
+
+				index = i;
+				break;
+			}
+		}
+	}
+
+	return index;
+}
+
+void ProjectList::ensure_project_visible(int p_index) {
+	const Item &item = _projects[p_index];
+	ensure_control_visible(item.control);
+}
+
+void ProjectList::_create_project_item_control(int p_index) {
+	// Will be added last in the list, so make sure indexes match
+	ERR_FAIL_COND(p_index != project_list_vbox->get_child_count());
+
+	Item &item = _projects.write[p_index];
+	ERR_FAIL_COND(item.control != nullptr); // Already created
+
+	ProjectListItemControl *hb = memnew(ProjectListItemControl);
+	hb->add_theme_constant_override("separation", 10 * EDSCALE);
+
+	hb->set_project_title(!item.missing ? item.project_name : TTR("Missing Project"));
+	hb->set_project_path(item.path);
+	hb->set_tooltip_text(item.description);
+	hb->set_tags(item.tags, this);
+	hb->set_unsupported_features(item.unsupported_features.duplicate());
+
+	hb->set_is_favorite(item.favorite);
+	hb->set_is_missing(item.missing);
+	hb->set_is_grayed(item.grayed);
+
+	hb->connect("gui_input", callable_mp(this, &ProjectList::_list_item_input).bind(hb));
+	hb->connect("favorite_pressed", callable_mp(this, &ProjectList::_on_favorite_pressed).bind(hb));
+
+#if !defined(ANDROID_ENABLED) && !defined(WEB_ENABLED)
+	hb->connect("explore_pressed", callable_mp(this, &ProjectList::_on_explore_pressed).bind(item.path));
+#endif
+
+	project_list_vbox->add_child(hb);
+	item.control = hb;
+}
+
+void ProjectList::_toggle_project(int p_index) {
+	// This methods adds to the selection or removes from the
+	// selection.
+	Item &item = _projects.write[p_index];
+
+	if (_selected_project_paths.has(item.path)) {
+		_deselect_project_nocheck(p_index);
+	} else {
+		_select_project_nocheck(p_index);
+	}
+}
+
+void ProjectList::_remove_project(int p_index, bool p_update_config) {
+	const Item item = _projects[p_index]; // Take a copy
+
+	_selected_project_paths.erase(item.path);
+
+	if (_last_clicked == item.path) {
+		_last_clicked = "";
+	}
+
+	memdelete(item.control);
+	_projects.remove_at(p_index);
+
+	if (p_update_config) {
+		_config.erase_section(item.path);
+		// Not actually saving the file, in case you are doing more changes to settings
+	}
+
+	update_dock_menu();
+}
+
+void ProjectList::_list_item_input(const Ref<InputEvent> &p_ev, Node *p_hb) {
+	Ref<InputEventMouseButton> mb = p_ev;
+	int clicked_index = p_hb->get_index();
+	const Item &clicked_project = _projects[clicked_index];
+
+	if (mb.is_valid() && mb->is_pressed() && mb->get_button_index() == MouseButton::LEFT) {
+		if (mb->is_shift_pressed() && _selected_project_paths.size() > 0 && !_last_clicked.is_empty() && clicked_project.path != _last_clicked) {
+			int anchor_index = -1;
+			for (int i = 0; i < _projects.size(); ++i) {
+				const Item &p = _projects[i];
+				if (p.path == _last_clicked) {
+					anchor_index = p.control->get_index();
+					break;
+				}
+			}
+			CRASH_COND(anchor_index == -1);
+			_select_project_range(anchor_index, clicked_index);
+
+		} else if (mb->is_command_or_control_pressed()) {
+			_toggle_project(clicked_index);
+
+		} else {
+			_last_clicked = clicked_project.path;
+			select_project(clicked_index);
+		}
+
+		emit_signal(SNAME(SIGNAL_SELECTION_CHANGED));
+
+		// Do not allow opening a project more than once using a single project manager instance.
+		// Opening the same project in several editor instances at once can lead to various issues.
+		if (!mb->is_command_or_control_pressed() && mb->is_double_click() && !project_opening_initiated) {
+			emit_signal(SNAME(SIGNAL_PROJECT_ASK_OPEN));
+		}
+	}
+}
+
+void ProjectList::_on_favorite_pressed(Node *p_hb) {
+	ProjectListItemControl *control = Object::cast_to<ProjectListItemControl>(p_hb);
+
+	int index = control->get_index();
+	Item item = _projects.write[index]; // Take copy
+
+	item.favorite = !item.favorite;
+
+	_config.set_value(item.path, "favorite", item.favorite);
+	save_config();
+
+	_projects.write[index] = item;
+
+	control->set_is_favorite(item.favorite);
+
+	sort_projects();
+
+	if (item.favorite) {
+		for (int i = 0; i < _projects.size(); ++i) {
+			if (_projects[i].path == item.path) {
+				ensure_project_visible(i);
+				break;
+			}
+		}
+	}
+
+	update_dock_menu();
+}
+
+void ProjectList::_on_explore_pressed(const String &p_path) {
+	OS::get_singleton()->shell_show_in_file_manager(p_path, true);
+}
+
+// Project list selection.
+
+void ProjectList::_clear_project_selection() {
+	Vector<Item> previous_selected_items = get_selected_projects();
+	_selected_project_paths.clear();
+
+	for (int i = 0; i < previous_selected_items.size(); ++i) {
+		previous_selected_items[i].control->set_selected(false);
+	}
+}
+
+void ProjectList::_select_project_nocheck(int p_index) {
+	Item &item = _projects.write[p_index];
+	_selected_project_paths.insert(item.path);
+	item.control->set_selected(true);
+}
+
+void ProjectList::_deselect_project_nocheck(int p_index) {
+	Item &item = _projects.write[p_index];
+	_selected_project_paths.erase(item.path);
+	item.control->set_selected(false);
+}
+
+inline void _sort_project_range(int &a, int &b) {
+	if (a > b) {
+		int temp = a;
+		a = b;
+		b = temp;
+	}
+}
+
+void ProjectList::_select_project_range(int p_begin, int p_end) {
+	_clear_project_selection();
+
+	_sort_project_range(p_begin, p_end);
+	for (int i = p_begin; i <= p_end; ++i) {
+		_select_project_nocheck(i);
+	}
+}
+
+void ProjectList::select_project(int p_index) {
+	// This method keeps only one project selected.
+	_clear_project_selection();
+	_select_project_nocheck(p_index);
+}
+
+void ProjectList::select_first_visible_project() {
+	_clear_project_selection();
+
+	for (int i = 0; i < _projects.size(); i++) {
+		if (_projects[i].control->is_visible()) {
+			_select_project_nocheck(i);
+			break;
+		}
+	}
+}
+
+Vector<ProjectList::Item> ProjectList::get_selected_projects() const {
+	Vector<Item> items;
+	if (_selected_project_paths.size() == 0) {
+		return items;
+	}
+	items.resize(_selected_project_paths.size());
+	int j = 0;
+	for (int i = 0; i < _projects.size(); ++i) {
+		const Item &item = _projects[i];
+		if (_selected_project_paths.has(item.path)) {
+			items.write[j++] = item;
+		}
+	}
+	ERR_FAIL_COND_V(j != items.size(), items);
+	return items;
+}
+
+const HashSet<String> &ProjectList::get_selected_project_keys() const {
+	// Faster if that's all you need
+	return _selected_project_paths;
+}
+
+int ProjectList::get_single_selected_index() const {
+	if (_selected_project_paths.size() == 0) {
+		// Default selection
+		return 0;
+	}
+	String key;
+	if (_selected_project_paths.size() == 1) {
+		// Only one selected
+		key = *_selected_project_paths.begin();
+	} else {
+		// Multiple selected, consider the last clicked one as "main"
+		key = _last_clicked;
+	}
+	for (int i = 0; i < _projects.size(); ++i) {
+		if (_projects[i].path == key) {
+			return i;
+		}
+	}
+	return 0;
+}
+
+void ProjectList::erase_selected_projects(bool p_delete_project_contents) {
+	if (_selected_project_paths.size() == 0) {
+		return;
+	}
+
+	for (int i = 0; i < _projects.size(); ++i) {
+		Item &item = _projects.write[i];
+		if (_selected_project_paths.has(item.path) && item.control->is_visible()) {
+			_config.erase_section(item.path);
+
+			// Comment out for now until we have a better warning system to
+			// ensure users delete their project only.
+			//if (p_delete_project_contents) {
+			//	OS::get_singleton()->move_to_trash(item.path);
+			//}
+
+			memdelete(item.control);
+			_projects.remove_at(i);
+			--i;
+		}
+	}
+
+	save_config();
+	_selected_project_paths.clear();
+	_last_clicked = "";
+
+	update_dock_menu();
+}
+
+// Missing projects.
+
+bool ProjectList::is_any_project_missing() const {
+	for (int i = 0; i < _projects.size(); ++i) {
+		if (_projects[i].missing) {
+			return true;
+		}
+	}
+	return false;
+}
+
+void ProjectList::erase_missing_projects() {
+	if (_projects.is_empty()) {
+		return;
+	}
+
+	int deleted_count = 0;
+	int remaining_count = 0;
+
+	for (int i = 0; i < _projects.size(); ++i) {
+		const Item &item = _projects[i];
+
+		if (item.missing) {
+			_remove_project(i, true);
+			--i;
+			++deleted_count;
+
+		} else {
+			++remaining_count;
+		}
+	}
+
+	print_line("Removed " + itos(deleted_count) + " projects from the list, remaining " + itos(remaining_count) + " projects");
+	save_config();
+}
+
+// Project list sorting and filtering.
+
+void ProjectList::set_search_term(String p_search_term) {
+	_search_term = p_search_term;
+}
+
+void ProjectList::add_search_tag(const String &p_tag) {
+	const String tag_string = "tag:" + p_tag;
+
+	int exists = _search_term.find(tag_string);
+	if (exists > -1) {
+		_search_term = _search_term.erase(exists, tag_string.length() + 1);
+	} else if (_search_term.is_empty() || _search_term.ends_with(" ")) {
+		_search_term += tag_string;
+	} else {
+		_search_term += " " + tag_string;
+	}
+	ProjectManager::get_singleton()->get_search_box()->set_text(_search_term);
+
+	sort_projects();
+}
+
+void ProjectList::set_order_option(int p_option) {
+	FilterOption selected = (FilterOption)p_option;
+	EditorSettings::get_singleton()->set("project_manager/sorting_order", p_option);
+	EditorSettings::get_singleton()->save();
+	_order_option = selected;
+
+	sort_projects();
+}
+
+// Global menu integration.
+
+void ProjectList::update_dock_menu() {
+	if (!DisplayServer::get_singleton()->has_feature(DisplayServer::FEATURE_GLOBAL_MENU)) {
+		return;
+	}
+	DisplayServer::get_singleton()->global_menu_clear("_dock");
+
+	int favs_added = 0;
+	int total_added = 0;
+	for (int i = 0; i < _projects.size(); ++i) {
+		if (!_projects[i].grayed && !_projects[i].missing) {
+			if (_projects[i].favorite) {
+				favs_added++;
+			} else {
+				if (favs_added != 0) {
+					DisplayServer::get_singleton()->global_menu_add_separator("_dock");
+				}
+				favs_added = 0;
+			}
+			DisplayServer::get_singleton()->global_menu_add_item("_dock", _projects[i].project_name + " ( " + _projects[i].path + " )", callable_mp(this, &ProjectList::_global_menu_open_project), Callable(), i);
+			total_added++;
+		}
+	}
+	if (total_added != 0) {
+		DisplayServer::get_singleton()->global_menu_add_separator("_dock");
+	}
+	DisplayServer::get_singleton()->global_menu_add_item("_dock", TTR("New Window"), callable_mp(this, &ProjectList::_global_menu_new_window));
+}
+
+void ProjectList::_global_menu_new_window(const Variant &p_tag) {
+	List<String> args;
+	args.push_back("-p");
+	OS::get_singleton()->create_instance(args);
+}
+
+void ProjectList::_global_menu_open_project(const Variant &p_tag) {
+	int idx = (int)p_tag;
+
+	if (idx >= 0 && idx < _projects.size()) {
+		String conf = _projects[idx].path.path_join("project.godot");
+		List<String> args;
+		args.push_back(conf);
+		OS::get_singleton()->create_instance(args);
+	}
+}
+
+// Object methods.
+
+void ProjectList::_bind_methods() {
+	ADD_SIGNAL(MethodInfo(SIGNAL_LIST_CHANGED));
+	ADD_SIGNAL(MethodInfo(SIGNAL_SELECTION_CHANGED));
+	ADD_SIGNAL(MethodInfo(SIGNAL_PROJECT_ASK_OPEN));
+}
+
+ProjectList::ProjectList() {
+	project_list_vbox = memnew(VBoxContainer);
+	project_list_vbox->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	add_child(project_list_vbox);
+
+	_config_path = EditorPaths::get_singleton()->get_data_dir().path_join("projects.cfg");
+	_migrate_config();
+}

+ 264 - 0
editor/project_manager/project_list.h

@@ -0,0 +1,264 @@
+/**************************************************************************/
+/*  project_list.h                                                        */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#ifndef PROJECT_LIST_H
+#define PROJECT_LIST_H
+
+#include "core/io/config_file.h"
+#include "scene/gui/box_container.h"
+#include "scene/gui/scroll_container.h"
+
+class Button;
+class Label;
+class ProjectList;
+class TextureButton;
+class TextureRect;
+
+class ProjectListItemControl : public HBoxContainer {
+	GDCLASS(ProjectListItemControl, HBoxContainer)
+
+	VBoxContainer *main_vbox = nullptr;
+	TextureButton *favorite_button = nullptr;
+	Button *explore_button = nullptr;
+
+	TextureRect *project_icon = nullptr;
+	Label *project_title = nullptr;
+	Label *project_path = nullptr;
+	TextureRect *project_unsupported_features = nullptr;
+	HBoxContainer *tag_container = nullptr;
+
+	bool project_is_missing = false;
+	bool icon_needs_reload = true;
+	bool is_selected = false;
+	bool is_hovering = false;
+
+	void _favorite_button_pressed();
+	void _explore_button_pressed();
+
+protected:
+	void _notification(int p_what);
+	static void _bind_methods();
+
+public:
+	void set_project_title(const String &p_title);
+	void set_project_path(const String &p_path);
+	void set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list);
+	void set_project_icon(const Ref<Texture2D> &p_icon);
+	void set_unsupported_features(PackedStringArray p_features);
+
+	bool should_load_project_icon() const;
+	void set_selected(bool p_selected);
+
+	void set_is_favorite(bool p_favorite);
+	void set_is_missing(bool p_missing);
+	void set_is_grayed(bool p_grayed);
+
+	ProjectListItemControl();
+};
+
+class ProjectList : public ScrollContainer {
+	GDCLASS(ProjectList, ScrollContainer)
+
+	friend class ProjectManager;
+
+public:
+	enum FilterOption {
+		EDIT_DATE,
+		NAME,
+		PATH,
+		TAGS,
+	};
+
+	// Can often be passed by copy.
+	struct Item {
+		String project_name;
+		String description;
+		PackedStringArray tags;
+		String tag_sort_string;
+		String path;
+		String icon;
+		String main_scene;
+		PackedStringArray unsupported_features;
+		uint64_t last_edited = 0;
+		bool favorite = false;
+		bool grayed = false;
+		bool missing = false;
+		int version = 0;
+
+		ProjectListItemControl *control = nullptr;
+
+		Item() {}
+
+		Item(const String &p_name,
+				const String &p_description,
+				const PackedStringArray &p_tags,
+				const String &p_path,
+				const String &p_icon,
+				const String &p_main_scene,
+				const PackedStringArray &p_unsupported_features,
+				uint64_t p_last_edited,
+				bool p_favorite,
+				bool p_grayed,
+				bool p_missing,
+				int p_version) {
+			project_name = p_name;
+			description = p_description;
+			tags = p_tags;
+			path = p_path;
+			icon = p_icon;
+			main_scene = p_main_scene;
+			unsupported_features = p_unsupported_features;
+			last_edited = p_last_edited;
+			favorite = p_favorite;
+			grayed = p_grayed;
+			missing = p_missing;
+			version = p_version;
+
+			control = nullptr;
+
+			PackedStringArray sorted_tags = tags;
+			sorted_tags.sort();
+			tag_sort_string = String().join(sorted_tags);
+		}
+
+		_FORCE_INLINE_ bool operator==(const Item &l) const {
+			return path == l.path;
+		}
+	};
+
+private:
+	String _config_path;
+	ConfigFile _config;
+
+	Vector<Item> _projects;
+
+	int _icon_load_index = 0;
+	bool project_opening_initiated = false;
+
+	String _search_term;
+	FilterOption _order_option = FilterOption::EDIT_DATE;
+	HashSet<String> _selected_project_paths;
+	String _last_clicked; // Project key
+
+	VBoxContainer *project_list_vbox = nullptr;
+
+	// Initialization & loading.
+
+	void _migrate_config();
+
+	static Item load_project_data(const String &p_property_key, bool p_favorite);
+	void _update_icons_async();
+	void _load_project_icon(int p_index);
+
+	// Project list updates.
+
+	void _scan_folder_recursive(const String &p_path, List<String> *r_projects);
+
+	// Project list items.
+
+	void _create_project_item_control(int p_index);
+	void _toggle_project(int p_index);
+	void _remove_project(int p_index, bool p_update_settings);
+
+	void _list_item_input(const Ref<InputEvent> &p_ev, Node *p_hb);
+	void _on_favorite_pressed(Node *p_hb);
+	void _on_explore_pressed(const String &p_path);
+
+	// Project list selection.
+
+	void _clear_project_selection();
+	void _select_project_nocheck(int p_index);
+	void _deselect_project_nocheck(int p_index);
+	void _select_project_range(int p_begin, int p_end);
+
+	// Global menu integration.
+
+	void _global_menu_new_window(const Variant &p_tag);
+	void _global_menu_open_project(const Variant &p_tag);
+
+protected:
+	void _notification(int p_what);
+	static void _bind_methods();
+
+public:
+	static const char *SIGNAL_LIST_CHANGED;
+	static const char *SIGNAL_SELECTION_CHANGED;
+	static const char *SIGNAL_PROJECT_ASK_OPEN;
+
+	static bool project_feature_looks_like_version(const String &p_feature);
+
+	// Initialization & loading.
+
+	void save_config();
+
+	// Project list updates.
+
+	void update_project_list();
+	void sort_projects();
+	int get_project_count() const;
+
+	void find_projects(const String &p_path);
+	void find_projects_multiple(const PackedStringArray &p_paths);
+
+	// Project list items.
+
+	void add_project(const String &dir_path, bool favorite);
+	void set_project_version(const String &p_project_path, int version);
+	int refresh_project(const String &dir_path);
+	void ensure_project_visible(int p_index);
+
+	// Project list selection.
+
+	void select_project(int p_index);
+	void select_first_visible_project();
+	Vector<Item> get_selected_projects() const;
+	const HashSet<String> &get_selected_project_keys() const;
+	int get_single_selected_index() const;
+	void erase_selected_projects(bool p_delete_project_contents);
+
+	// Missing projects.
+
+	bool is_any_project_missing() const;
+	void erase_missing_projects();
+
+	// Project list sorting and filtering.
+
+	void set_search_term(String p_search_term);
+	void add_search_tag(const String &p_tag);
+	void set_order_option(int p_option);
+
+	// Global menu integration.
+
+	void update_dock_menu();
+
+	ProjectList();
+};
+
+#endif // PROJECT_LIST_H

+ 74 - 0
editor/project_manager/project_tag.cpp

@@ -0,0 +1,74 @@
+/**************************************************************************/
+/*  project_tag.cpp                                                       */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#include "project_tag.h"
+
+#include "editor/themes/editor_scale.h"
+#include "scene/gui/button.h"
+#include "scene/gui/color_rect.h"
+
+void ProjectTag::_notification(int p_what) {
+	if (display_close && p_what == NOTIFICATION_THEME_CHANGED) {
+		button->set_icon(get_theme_icon(SNAME("close"), SNAME("TabBar")));
+	}
+}
+
+void ProjectTag::connect_button_to(const Callable &p_callable) {
+	button->connect(SNAME("pressed"), p_callable, CONNECT_DEFERRED);
+}
+
+const String ProjectTag::get_tag() const {
+	return tag_string;
+}
+
+ProjectTag::ProjectTag(const String &p_text, bool p_display_close) {
+	add_theme_constant_override(SNAME("separation"), 0);
+	set_v_size_flags(SIZE_SHRINK_CENTER);
+	tag_string = p_text;
+	display_close = p_display_close;
+
+	Color tag_color = Color(1, 0, 0);
+	tag_color.set_ok_hsl_s(0.8);
+	tag_color.set_ok_hsl_h(float(p_text.hash() * 10001 % UINT32_MAX) / float(UINT32_MAX));
+	set_self_modulate(tag_color);
+
+	ColorRect *cr = memnew(ColorRect);
+	add_child(cr);
+	cr->set_custom_minimum_size(Vector2(4, 0) * EDSCALE);
+	cr->set_color(tag_color);
+
+	button = memnew(Button);
+	add_child(button);
+	button->set_auto_translate(false);
+	button->set_text(p_text.capitalize());
+	button->set_focus_mode(FOCUS_NONE);
+	button->set_icon_alignment(HORIZONTAL_ALIGNMENT_RIGHT);
+	button->set_theme_type_variation(SNAME("ProjectTag"));
+}

+ 56 - 0
editor/project_manager/project_tag.h

@@ -0,0 +1,56 @@
+/**************************************************************************/
+/*  project_tag.h                                                         */
+/**************************************************************************/
+/*                         This file is part of:                          */
+/*                             GODOT ENGINE                               */
+/*                        https://godotengine.org                         */
+/**************************************************************************/
+/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
+/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
+/*                                                                        */
+/* Permission is hereby granted, free of charge, to any person obtaining  */
+/* a copy of this software and associated documentation files (the        */
+/* "Software"), to deal in the Software without restriction, including    */
+/* without limitation the rights to use, copy, modify, merge, publish,    */
+/* distribute, sublicense, and/or sell copies of the Software, and to     */
+/* permit persons to whom the Software is furnished to do so, subject to  */
+/* the following conditions:                                              */
+/*                                                                        */
+/* The above copyright notice and this permission notice shall be         */
+/* included in all copies or substantial portions of the Software.        */
+/*                                                                        */
+/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
+/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
+/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
+/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
+/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
+/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
+/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
+/**************************************************************************/
+
+#ifndef PROJECT_TAG_H
+#define PROJECT_TAG_H
+
+#include "scene/gui/box_container.h"
+
+class Button;
+
+class ProjectTag : public HBoxContainer {
+	GDCLASS(ProjectTag, HBoxContainer);
+
+	String tag_string;
+	bool display_close = false;
+
+	Button *button = nullptr;
+
+protected:
+	void _notification(int p_what);
+
+public:
+	void connect_button_to(const Callable &p_callable);
+	const String get_tag() const;
+
+	ProjectTag(const String &p_text, bool p_display_close = false);
+};
+
+#endif // PROJECT_TAG_H

Some files were not shown because too many files changed in this diff