2
0
Эх сурвалжийг харах

Merge pull request #81620 from YuriSizov/assets-remap-install-folder

Allow to specify target folder when installing assets
Rémi Verschelde 1 жил өмнө
parent
commit
8f0609c00a

+ 351 - 88
editor/editor_asset_installer.cpp

@@ -35,31 +35,48 @@
 #include "core/io/zip_io.h"
 #include "editor/editor_file_system.h"
 #include "editor/editor_node.h"
+#include "editor/editor_scale.h"
 #include "editor/editor_string_names.h"
+#include "editor/gui/editor_file_dialog.h"
 #include "editor/gui/editor_toaster.h"
 #include "editor/progress_dialog.h"
 #include "scene/gui/check_box.h"
 #include "scene/gui/label.h"
+#include "scene/gui/link_button.h"
+#include "scene/gui/separator.h"
+#include "scene/gui/split_container.h"
 
-void EditorAssetInstaller::_item_checked() {
-	if (updating || !tree->get_edited()) {
+void EditorAssetInstaller::_item_checked_cbk() {
+	if (updating_source || !source_tree->get_edited()) {
 		return;
 	}
 
-	updating = true;
-	TreeItem *item = tree->get_edited();
+	updating_source = true;
+	TreeItem *item = source_tree->get_edited();
 	item->propagate_check(0);
-	updating = false;
+	_update_confirm_button();
+	_rebuild_destination_tree();
+	updating_source = false;
 }
 
-void EditorAssetInstaller::_check_propagated_to_item(Object *p_obj, int column) {
+void EditorAssetInstaller::_check_propagated_to_item(Object *p_obj, int p_column) {
 	TreeItem *affected_item = Object::cast_to<TreeItem>(p_obj);
-	if (affected_item && affected_item->get_custom_color(0) != Color()) {
+	if (!affected_item) {
+		return;
+	}
+
+	Dictionary item_meta = affected_item->get_metadata(0);
+	bool is_conflict = item_meta.get("is_conflict", false);
+	if (is_conflict) {
 		affected_item->set_checked(0, false);
 		affected_item->propagate_check(0, false);
 	}
 }
 
+bool EditorAssetInstaller::_is_item_checked(const String &p_source_path) const {
+	return file_item_map.has(p_source_path) && (file_item_map[p_source_path]->is_checked(0) || file_item_map[p_source_path]->is_indeterminate(0));
+}
+
 void EditorAssetInstaller::open_asset(const String &p_path, bool p_autoskip_toplevel) {
 	package_path = p_path;
 	asset_files.clear();
@@ -104,27 +121,24 @@ void EditorAssetInstaller::open_asset(const String &p_path, bool p_autoskip_topl
 
 	unzClose(pkg);
 
+	asset_title_label->set_text(asset_name);
+
 	_check_has_toplevel();
 	// Default to false, unless forced.
 	skip_toplevel = p_autoskip_toplevel;
+	skip_toplevel_check->set_block_signals(true);
 	skip_toplevel_check->set_pressed(!skip_toplevel_check->is_disabled() && skip_toplevel);
+	skip_toplevel_check->set_block_signals(false);
 
-	_rebuild_tree();
-}
-
-void EditorAssetInstaller::_rebuild_tree() {
-	updating = true;
-	tree->clear();
+	_update_file_mappings();
+	_rebuild_source_tree();
+	_rebuild_destination_tree();
 
-	TreeItem *root = tree->create_item();
-	root->set_cell_mode(0, TreeItem::CELL_MODE_CHECK);
-	root->set_checked(0, true);
-	root->set_icon(0, get_theme_icon(SNAME("folder"), SNAME("FileDialog")));
-	root->set_text(0, "res://");
-	root->set_editable(0, true);
+	popup_centered_clamped(Size2(620, 640) * EDSCALE);
+}
 
-	HashMap<String, TreeItem *> directory_item_map;
-	int num_file_conflicts = 0;
+void EditorAssetInstaller::_update_file_mappings() {
+	mapped_files.clear();
 
 	bool first = true;
 	for (const String &E : asset_files) {
@@ -141,9 +155,31 @@ void EditorAssetInstaller::_rebuild_tree() {
 			path = path.trim_prefix(toplevel_prefix);
 		}
 
+		mapped_files[E] = path;
+	}
+}
+
+void EditorAssetInstaller::_rebuild_source_tree() {
+	updating_source = true;
+	source_tree->clear();
+
+	TreeItem *root = source_tree->create_item();
+	root->set_cell_mode(0, TreeItem::CELL_MODE_CHECK);
+	root->set_checked(0, true);
+	root->set_icon(0, get_theme_icon(SNAME("folder"), SNAME("FileDialog")));
+	root->set_text(0, "/");
+	root->set_editable(0, true);
+
+	file_item_map.clear();
+	HashMap<String, TreeItem *> directory_item_map;
+	int num_file_conflicts = 0;
+	first_file_conflict = nullptr;
+
+	for (const String &E : asset_files) {
+		String path = E; // We're going to mutate it.
+
 		bool is_directory = false;
 		if (path.ends_with("/")) {
-			// Directory.
 			path = path.trim_suffix("/");
 			is_directory = true;
 		}
@@ -162,45 +198,155 @@ void EditorAssetInstaller::_rebuild_tree() {
 
 		TreeItem *ti;
 		if (is_directory) {
-			ti = _create_dir_item(parent_item, path, directory_item_map);
+			ti = _create_dir_item(source_tree, parent_item, path, directory_item_map);
 		} else {
-			ti = _create_file_item(parent_item, path, &num_file_conflicts);
+			ti = _create_file_item(source_tree, parent_item, path, &num_file_conflicts);
 		}
 		file_item_map[E] = ti;
 	}
 
-	asset_title_label->set_text(asset_name);
-	if (num_file_conflicts >= 1) {
-		asset_conflicts_label->set_text(vformat(TTRN("%d file conflicts with your project", "%d files conflict with your project", num_file_conflicts), num_file_conflicts));
-		asset_conflicts_label->add_theme_color_override("font_color", get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
+	_update_conflict_status(num_file_conflicts);
+	_update_confirm_button();
+
+	updating_source = false;
+}
+
+void EditorAssetInstaller::_update_source_tree() {
+	int num_file_conflicts = 0;
+	first_file_conflict = nullptr;
+
+	for (const KeyValue<String, TreeItem *> &E : file_item_map) {
+		TreeItem *ti = E.value;
+		Dictionary item_meta = ti->get_metadata(0);
+		if ((bool)item_meta.get("is_dir", false)) {
+			continue;
+		}
+
+		String asset_path = item_meta.get("asset_path", "");
+		ERR_CONTINUE(asset_path.is_empty());
+
+		bool target_exists = _update_source_item_status(ti, asset_path);
+		if (target_exists) {
+			if (first_file_conflict == nullptr) {
+				first_file_conflict = ti;
+			}
+			num_file_conflicts += 1;
+		}
+
+		item_meta["is_conflict"] = target_exists;
+		ti->set_metadata(0, item_meta);
+	}
+
+	_update_conflict_status(num_file_conflicts);
+	_update_confirm_button();
+}
+
+bool EditorAssetInstaller::_update_source_item_status(TreeItem *p_item, const String &p_path) {
+	ERR_FAIL_COND_V(!mapped_files.has(p_path), false);
+	String target_path = target_dir_path.path_join(mapped_files[p_path]);
+
+	bool target_exists = FileAccess::exists(target_path);
+	if (target_exists) {
+		p_item->set_custom_color(0, get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
+		p_item->set_tooltip_text(0, vformat(TTR("%s (already exists)"), target_path));
+		p_item->set_checked(0, false);
 	} else {
-		asset_conflicts_label->set_text(TTR("No files conflict with your project"));
-		asset_conflicts_label->remove_theme_color_override("font_color");
+		p_item->clear_custom_color(0);
+		p_item->set_tooltip_text(0, target_path);
+		p_item->set_checked(0, true);
 	}
 
-	popup_centered_ratio(0.5);
-	updating = false;
+	p_item->propagate_check(0);
+	return target_exists;
 }
 
-TreeItem *EditorAssetInstaller::_create_dir_item(TreeItem *p_parent, const String &p_path, HashMap<String, TreeItem *> &p_item_map) {
-	TreeItem *ti = tree->create_item(p_parent);
-	ti->set_cell_mode(0, TreeItem::CELL_MODE_CHECK);
-	ti->set_checked(0, true);
-	ti->set_editable(0, true);
+void EditorAssetInstaller::_rebuild_destination_tree() {
+	destination_tree->clear();
+
+	TreeItem *root = destination_tree->create_item();
+	root->set_icon(0, get_theme_icon(SNAME("folder"), SNAME("FileDialog")));
+	root->set_text(0, target_dir_path + (target_dir_path == "res://" ? "" : "/"));
+
+	HashMap<String, TreeItem *> directory_item_map;
+
+	for (const KeyValue<String, String> &E : mapped_files) {
+		if (!_is_item_checked(E.key)) {
+			continue;
+		}
+
+		String path = E.value; // We're going to mutate it.
+
+		bool is_directory = false;
+		if (path.ends_with("/")) {
+			path = path.trim_suffix("/");
+			is_directory = true;
+		}
+
+		TreeItem *parent_item;
+
+		int separator = path.rfind("/");
+		if (separator == -1) {
+			parent_item = root;
+		} else {
+			String parent_path = path.substr(0, separator);
+			HashMap<String, TreeItem *>::Iterator I = directory_item_map.find(parent_path);
+			ERR_CONTINUE(!I);
+			parent_item = I->value;
+		}
+
+		if (is_directory) {
+			_create_dir_item(destination_tree, parent_item, path, directory_item_map);
+		} else {
+			int num_file_conflicts = 0; // Don't need it, but need to pass something.
+			_create_file_item(destination_tree, parent_item, path, &num_file_conflicts);
+		}
+	}
+}
+
+TreeItem *EditorAssetInstaller::_create_dir_item(Tree *p_tree, TreeItem *p_parent, const String &p_path, HashMap<String, TreeItem *> &p_item_map) {
+	TreeItem *ti = p_tree->create_item(p_parent);
+
+	if (p_tree == source_tree) {
+		ti->set_cell_mode(0, TreeItem::CELL_MODE_CHECK);
+		ti->set_editable(0, true);
+		ti->set_checked(0, true);
+		ti->propagate_check(0);
+
+		Dictionary meta;
+		meta["asset_path"] = p_path + "/";
+		meta["is_dir"] = true;
+		meta["is_conflict"] = false;
+		ti->set_metadata(0, meta);
+	}
 
 	ti->set_text(0, p_path.get_file() + "/");
 	ti->set_icon(0, get_theme_icon(SNAME("folder"), SNAME("FileDialog")));
-	ti->set_metadata(0, String());
 
 	p_item_map[p_path] = ti;
 	return ti;
 }
 
-TreeItem *EditorAssetInstaller::_create_file_item(TreeItem *p_parent, const String &p_path, int *r_conflicts) {
-	TreeItem *ti = tree->create_item(p_parent);
-	ti->set_cell_mode(0, TreeItem::CELL_MODE_CHECK);
-	ti->set_checked(0, true);
-	ti->set_editable(0, true);
+TreeItem *EditorAssetInstaller::_create_file_item(Tree *p_tree, TreeItem *p_parent, const String &p_path, int *r_conflicts) {
+	TreeItem *ti = p_tree->create_item(p_parent);
+
+	if (p_tree == source_tree) {
+		ti->set_cell_mode(0, TreeItem::CELL_MODE_CHECK);
+		ti->set_editable(0, true);
+
+		bool target_exists = _update_source_item_status(ti, p_path);
+		if (target_exists) {
+			if (first_file_conflict == nullptr) {
+				first_file_conflict = ti;
+			}
+			*r_conflicts += 1;
+		}
+
+		Dictionary meta;
+		meta["asset_path"] = p_path;
+		meta["is_dir"] = false;
+		meta["is_conflict"] = target_exists;
+		ti->set_metadata(0, meta);
+	}
 
 	String file = p_path.get_file();
 	String extension = file.get_extension().to_lower();
@@ -211,20 +357,38 @@ TreeItem *EditorAssetInstaller::_create_file_item(TreeItem *p_parent, const Stri
 	}
 	ti->set_text(0, file);
 
-	String res_path = "res://" + p_path;
-	if (FileAccess::exists(res_path)) {
-		*r_conflicts += 1;
-		ti->set_custom_color(0, get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
-		ti->set_tooltip_text(0, vformat(TTR("%s (already exists)"), res_path));
-		ti->set_checked(0, false);
-		ti->propagate_check(0);
+	return ti;
+}
+
+void EditorAssetInstaller::_update_conflict_status(int p_conflicts) {
+	if (p_conflicts >= 1) {
+		asset_conflicts_link->set_text(vformat(TTRN("%d file conflicts with your project and won't be installed", "%d files conflict with your project and won't be installed", p_conflicts), p_conflicts));
+		asset_conflicts_link->show();
+		asset_conflicts_label->hide();
 	} else {
-		ti->set_tooltip_text(0, res_path);
+		asset_conflicts_link->hide();
+		asset_conflicts_label->show();
 	}
+}
 
-	ti->set_metadata(0, res_path);
+void EditorAssetInstaller::_update_confirm_button() {
+	TreeItem *root = source_tree->get_root();
+	get_ok_button()->set_disabled(!root || (!root->is_checked(0) && !root->is_indeterminate(0)));
+}
 
-	return ti;
+void EditorAssetInstaller::_toggle_source_tree(bool p_visible, bool p_scroll_to_error) {
+	source_tree_vb->set_visible(p_visible);
+	show_source_files_button->set_pressed_no_signal(p_visible); // To keep in sync if triggered by something else.
+
+	if (p_visible) {
+		show_source_files_button->set_icon(get_editor_theme_icon(SNAME("Back")));
+	} else {
+		show_source_files_button->set_icon(get_editor_theme_icon(SNAME("Forward")));
+	}
+
+	if (p_visible && p_scroll_to_error && first_file_conflict) {
+		source_tree->scroll_to_item(first_file_conflict, true);
+	}
 }
 
 void EditorAssetInstaller::_check_has_toplevel() {
@@ -259,12 +423,42 @@ void EditorAssetInstaller::_check_has_toplevel() {
 
 	toplevel_prefix = first_asset;
 	skip_toplevel_check->set_disabled(false);
-	skip_toplevel_check->set_tooltip_text("");
+	skip_toplevel_check->set_tooltip_text(TTR("Ignore the root directory when extracting files."));
 }
 
 void EditorAssetInstaller::_set_skip_toplevel(bool p_checked) {
+	if (skip_toplevel == p_checked) {
+		return;
+	}
+
 	skip_toplevel = p_checked;
-	_rebuild_tree();
+	_update_file_mappings();
+	_update_source_tree();
+	_rebuild_destination_tree();
+}
+
+void EditorAssetInstaller::_open_target_dir_dialog() {
+	if (!target_dir_dialog) {
+		target_dir_dialog = memnew(EditorFileDialog);
+		target_dir_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_DIR);
+		target_dir_dialog->set_title(TTR("Select Install Folder"));
+		target_dir_dialog->set_current_dir(target_dir_path);
+		target_dir_dialog->connect("dir_selected", callable_mp(this, &EditorAssetInstaller::_target_dir_selected));
+		add_child(target_dir_dialog);
+	}
+
+	target_dir_dialog->popup_file_dialog();
+}
+
+void EditorAssetInstaller::_target_dir_selected(const String &p_target_path) {
+	if (target_dir_path == p_target_path) {
+		return;
+	}
+
+	target_dir_path = p_target_path;
+	_update_file_mappings();
+	_update_source_tree();
+	_rebuild_destination_tree();
 }
 
 void EditorAssetInstaller::ok_pressed() {
@@ -296,26 +490,25 @@ void EditorAssetInstaller::_install_asset() {
 		}
 
 		String source_name = String::utf8(fname);
-		if (!file_item_map.has(source_name) || (!file_item_map[source_name]->is_checked(0) && !file_item_map[source_name]->is_indeterminate(0))) {
+		if (!_is_item_checked(source_name)) {
 			continue;
 		}
 
-		String path = file_item_map[source_name]->get_metadata(0);
-		if (path.is_empty()) { // Directory.
-			// TODO: Metadata can be used to store the entire path of directories too,
-			// so this tree iteration can be avoided.
-			String dir_path;
-			TreeItem *t = file_item_map[source_name];
-			while (t) {
-				dir_path = t->get_text(0) + dir_path;
-				t = t->get_parent();
-			}
+		HashMap<String, String>::Iterator E = mapped_files.find(source_name);
+		if (!E) {
+			continue; // No remapped path means we don't want it; most likely the root.
+		}
+
+		String target_path = target_dir_path.path_join(E->value);
 
-			if (dir_path.ends_with("/")) {
-				dir_path = dir_path.substr(0, dir_path.length() - 1);
+		Dictionary asset_meta = file_item_map[source_name]->get_metadata(0);
+		bool is_dir = asset_meta.get("is_dir", false);
+		if (is_dir) {
+			if (target_path.ends_with("/")) {
+				target_path = target_path.substr(0, target_path.length() - 1);
 			}
 
-			da->make_dir_recursive(dir_path);
+			da->make_dir_recursive(target_path);
 		} else {
 			Vector<uint8_t> uncomp_data;
 			uncomp_data.resize(info.uncompressed_size);
@@ -325,16 +518,16 @@ void EditorAssetInstaller::_install_asset() {
 			unzCloseCurrentFile(pkg);
 
 			// Ensure that the target folder exists.
-			da->make_dir_recursive(path.get_base_dir());
+			da->make_dir_recursive(target_path.get_base_dir());
 
-			Ref<FileAccess> f = FileAccess::open(path, FileAccess::WRITE);
+			Ref<FileAccess> f = FileAccess::open(target_path, FileAccess::WRITE);
 			if (f.is_valid()) {
 				f->store_buffer(uncomp_data.ptr(), uncomp_data.size());
 			} else {
-				failed_files.push_back(path);
+				failed_files.push_back(target_path);
 			}
 
-			ProgressDialog::get_singleton()->task_step("uncompress", path, idx);
+			ProgressDialog::get_singleton()->task_step("uncompress", target_path, idx);
 		}
 	}
 
@@ -358,6 +551,7 @@ void EditorAssetInstaller::_install_asset() {
 			EditorNode::get_singleton()->show_warning(vformat(TTR("Asset \"%s\" installed successfully!"), asset_name), TTR("Success!"));
 		}
 	}
+
 	EditorFileSystem::get_singleton()->scan_changes();
 }
 
@@ -372,6 +566,13 @@ String EditorAssetInstaller::get_asset_name() const {
 void EditorAssetInstaller::_notification(int p_what) {
 	switch (p_what) {
 		case NOTIFICATION_THEME_CHANGED: {
+			if (show_source_files_button->is_pressed()) {
+				show_source_files_button->set_icon(get_editor_theme_icon(SNAME("Back")));
+			} else {
+				show_source_files_button->set_icon(get_editor_theme_icon(SNAME("Forward")));
+			}
+			asset_conflicts_link->add_theme_color_override("font_color", get_theme_color(SNAME("error_color"), EditorStringName(Editor)));
+
 			generic_extension_icon = get_editor_theme_icon(SNAME("Object"));
 
 			extension_icon_map.clear();
@@ -435,36 +636,98 @@ EditorAssetInstaller::EditorAssetInstaller() {
 	VBoxContainer *vb = memnew(VBoxContainer);
 	add_child(vb);
 
+	// Status bar.
+
 	HBoxContainer *asset_status = memnew(HBoxContainer);
 	vb->add_child(asset_status);
 
 	Label *asset_label = memnew(Label);
-	asset_label->set_text(TTR("Contents of asset:"));
+	asset_label->set_text(TTR("Asset:"));
+	asset_label->set_theme_type_variation("HeaderSmall");
 	asset_status->add_child(asset_label);
 
 	asset_title_label = memnew(Label);
-	asset_title_label->set_theme_type_variation("HeaderSmall");
 	asset_status->add_child(asset_title_label);
 
-	asset_status->add_spacer();
+	// File remapping controls.
 
-	asset_conflicts_label = memnew(Label);
-	asset_conflicts_label->set_theme_type_variation("HeaderSmall");
-	asset_status->add_child(asset_conflicts_label);
+	HBoxContainer *remapping_tools = memnew(HBoxContainer);
+	vb->add_child(remapping_tools);
+
+	show_source_files_button = memnew(Button);
+	show_source_files_button->set_toggle_mode(true);
+	show_source_files_button->set_tooltip_text(TTR("Open the list of the asset contents and select which files to install."));
+	remapping_tools->add_child(show_source_files_button);
+	show_source_files_button->connect("toggled", callable_mp(this, &EditorAssetInstaller::_toggle_source_tree).bind(false));
+
+	Button *target_dir_button = memnew(Button);
+	target_dir_button->set_text(TTR("Change Install Folder"));
+	target_dir_button->set_tooltip_text(TTR("Change the folder where the contents of the asset are going to be installed."));
+	remapping_tools->add_child(target_dir_button);
+	target_dir_button->connect("pressed", callable_mp(this, &EditorAssetInstaller::_open_target_dir_dialog));
+
+	remapping_tools->add_child(memnew(VSeparator));
 
 	skip_toplevel_check = memnew(CheckBox);
-	skip_toplevel_check->set_text(TTR("Ignore the root directory when extracting files"));
-	skip_toplevel_check->set_h_size_flags(Control::SIZE_SHRINK_BEGIN);
+	skip_toplevel_check->set_text(TTR("Ignore asset root"));
+	skip_toplevel_check->set_tooltip_text(TTR("Ignore the root directory when extracting files."));
 	skip_toplevel_check->connect("toggled", callable_mp(this, &EditorAssetInstaller::_set_skip_toplevel));
-	vb->add_child(skip_toplevel_check);
+	remapping_tools->add_child(skip_toplevel_check);
 
-	tree = memnew(Tree);
-	tree->set_v_size_flags(Control::SIZE_EXPAND_FILL);
-	tree->connect("item_edited", callable_mp(this, &EditorAssetInstaller::_item_checked));
-	tree->connect("check_propagated_to_item", callable_mp(this, &EditorAssetInstaller::_check_propagated_to_item));
-	vb->add_child(tree);
+	remapping_tools->add_spacer();
 
-	set_title(TTR("Select Asset Files to Install"));
+	asset_conflicts_label = memnew(Label);
+	asset_conflicts_label->set_theme_type_variation("HeaderSmall");
+	asset_conflicts_label->set_text(TTR("No files conflict with your project"));
+	remapping_tools->add_child(asset_conflicts_label);
+	asset_conflicts_link = memnew(LinkButton);
+	asset_conflicts_link->set_theme_type_variation("HeaderSmallLink");
+	asset_conflicts_link->set_v_size_flags(Control::SIZE_SHRINK_CENTER);
+	asset_conflicts_link->set_tooltip_text(TTR("Show contents of the asset and conflicting files."));
+	asset_conflicts_link->set_visible(false);
+	remapping_tools->add_child(asset_conflicts_link);
+	asset_conflicts_link->connect("pressed", callable_mp(this, &EditorAssetInstaller::_toggle_source_tree).bind(true, true));
+
+	// File hierarchy trees.
+
+	HSplitContainer *tree_split = memnew(HSplitContainer);
+	tree_split->set_v_size_flags(Control::SIZE_EXPAND_FILL);
+	vb->add_child(tree_split);
+
+	source_tree_vb = memnew(VBoxContainer);
+	source_tree_vb->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	source_tree_vb->set_visible(show_source_files_button->is_pressed());
+	tree_split->add_child(source_tree_vb);
+
+	Label *source_tree_label = memnew(Label);
+	source_tree_label->set_text(TTR("Contents of the asset:"));
+	source_tree_label->set_theme_type_variation("HeaderSmall");
+	source_tree_vb->add_child(source_tree_label);
+
+	source_tree = memnew(Tree);
+	source_tree->set_v_size_flags(Control::SIZE_EXPAND_FILL);
+	source_tree->connect("item_edited", callable_mp(this, &EditorAssetInstaller::_item_checked_cbk));
+	source_tree->connect("check_propagated_to_item", callable_mp(this, &EditorAssetInstaller::_check_propagated_to_item));
+	source_tree_vb->add_child(source_tree);
+
+	VBoxContainer *destination_tree_vb = memnew(VBoxContainer);
+	destination_tree_vb->set_h_size_flags(Control::SIZE_EXPAND_FILL);
+	tree_split->add_child(destination_tree_vb);
+
+	Label *destination_tree_label = memnew(Label);
+	destination_tree_label->set_text(TTR("Installation preview:"));
+	destination_tree_label->set_theme_type_variation("HeaderSmall");
+	destination_tree_vb->add_child(destination_tree_label);
+
+	destination_tree = memnew(Tree);
+	destination_tree->set_v_size_flags(Control::SIZE_EXPAND_FILL);
+	destination_tree->connect("item_edited", callable_mp(this, &EditorAssetInstaller::_item_checked_cbk));
+	destination_tree->connect("check_propagated_to_item", callable_mp(this, &EditorAssetInstaller::_check_propagated_to_item));
+	destination_tree_vb->add_child(destination_tree);
+
+	// Dialog configuration.
+
+	set_title(TTR("Configure Asset Before Installing"));
 	set_ok_button_text(TTR("Install"));
 	set_hide_on_ok(true);
 }

+ 31 - 7
editor/editor_asset_installer.h

@@ -35,37 +35,61 @@
 #include "scene/gui/tree.h"
 
 class CheckBox;
+class EditorFileDialog;
 class Label;
+class LinkButton;
 
 class EditorAssetInstaller : public ConfirmationDialog {
 	GDCLASS(EditorAssetInstaller, ConfirmationDialog);
 
-	Tree *tree = nullptr;
+	VBoxContainer *source_tree_vb = nullptr;
+	Tree *source_tree = nullptr;
+	Tree *destination_tree = nullptr;
 	Label *asset_title_label = nullptr;
 	Label *asset_conflicts_label = nullptr;
+	LinkButton *asset_conflicts_link = nullptr;
+
+	Button *show_source_files_button = nullptr;
 	CheckBox *skip_toplevel_check = nullptr;
+	EditorFileDialog *target_dir_dialog = nullptr;
 
 	String package_path;
 	String asset_name;
 	HashSet<String> asset_files;
+	HashMap<String, String> mapped_files;
 	HashMap<String, TreeItem *> file_item_map;
 
+	TreeItem *first_file_conflict = nullptr;
+
 	Ref<Texture2D> generic_extension_icon;
 	HashMap<String, Ref<Texture2D>> extension_icon_map;
 
-	bool updating = false;
+	bool updating_source = false;
 	String toplevel_prefix;
 	bool skip_toplevel = false;
+	String target_dir_path = "res://";
 
 	void _check_has_toplevel();
 	void _set_skip_toplevel(bool p_checked);
 
-	void _rebuild_tree();
-	TreeItem *_create_dir_item(TreeItem *p_parent, const String &p_path, HashMap<String, TreeItem *> &p_item_map);
-	TreeItem *_create_file_item(TreeItem *p_parent, const String &p_path, int *r_conflicts);
+	void _open_target_dir_dialog();
+	void _target_dir_selected(const String &p_target_path);
+
+	void _update_file_mappings();
+	void _rebuild_source_tree();
+	void _update_source_tree();
+	bool _update_source_item_status(TreeItem *p_item, const String &p_path);
+	void _rebuild_destination_tree();
+	TreeItem *_create_dir_item(Tree *p_tree, TreeItem *p_parent, const String &p_path, HashMap<String, TreeItem *> &p_item_map);
+	TreeItem *_create_file_item(Tree *p_tree, TreeItem *p_parent, const String &p_path, int *r_conflicts);
+
+	void _update_conflict_status(int p_conflicts);
+	void _update_confirm_button();
+	void _toggle_source_tree(bool p_visible, bool p_scroll_to_error = false);
 
-	void _item_checked();
-	void _check_propagated_to_item(Object *p_obj, int column);
+	void _item_checked_cbk();
+	void _check_propagated_to_item(Object *p_obj, int p_column);
+	bool _is_item_checked(const String &p_source_path) const;
 
 	void _install_asset();
 	virtual void ok_pressed() override;

+ 4 - 0
editor/editor_themes.cpp

@@ -1917,6 +1917,10 @@ Ref<Theme> create_editor_theme(const Ref<Theme> p_theme) {
 
 	theme->set_constant("outline_size", "LinkButton", 0);
 
+	theme->set_type_variation("HeaderSmallLink", "LinkButton");
+	theme->set_font("font", "HeaderSmallLink", theme->get_font(SNAME("font"), SNAME("HeaderSmall")));
+	theme->set_font_size("font_size", "HeaderSmallLink", theme->get_font_size(SNAME("font_size"), SNAME("HeaderSmall")));
+
 	// TooltipPanel + TooltipLabel
 	// TooltipPanel is also used for custom tooltips, while TooltipLabel
 	// is only relevant for default tooltips.