Mod manager: offer to uninstall what a mod pack brought in

Installing a mod pack installs its dependencies, but uninstalling it left
them all behind. The uninstall confirmation now lists the mods the chosen
ones list as dependencies (and theirs in turn) that no mod staying
installed still lists, with "Also uninstall the N mods it brought in,
which nothing else needs" ticked. A dependency another mod needs is never
offered; unticked, only the chosen mods go, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nxrootandClaude Opus 5.5 committed 2026-10-22 12:30:05 +01:00
1 parent 45346b122c
commit 02e8ced717
5 files changed
+139 -7

No files matched your search

+24
View File
@@ -466,6 +466,30 @@ int main() {
"Only a retired mod is left out");
}
// A mod pack's dependencies: what it brought in and nothing else needs, through their own dependencies.
{
const auto packs = root / L"pack-mods";
const auto manifest = [&](const char* folder, const char* dependencies) {
write(packs / folder / "manifest.json",
std::string(R"({"name":"x","version_number":"1.0.0","dependencies":)") + dependencies + "}");
};
manifest("Me-Pack", R"(["Team-A-1.0.0","Team-B-2.0.0","Team-Gone-1.0.0"])");
manifest("Team-A", R"(["Team-C-1.0.0"])");
manifest("Team-B", "[]");
manifest("Team-C", "[]");
manifest("Me-Other", R"(["team-b-2.0.0"])");
manifest("Team-D", "[]");
const std::vector<std::string> all{"Team-D", "Team-C", "Me-Other", "Team-B", "Team-A", "Me-Pack"};
check(launcher_mods::orphaned_dependencies(packs, {"Me-Pack"}, all) == std::vector<std::string>{"Team-C", "Team-A"},
"A pack's dependencies (and theirs) were not offered, or one another mod needs was");
check(launcher_mods::orphaned_dependencies(packs, {"Me-Pack", "Me-Other"}, all) ==
std::vector<std::string>{"Team-C", "Team-B", "Team-A"},
"A dependency only removed mods need was not offered");
check(launcher_mods::orphaned_dependencies(packs, {"Team-D"}, all).empty(), "A mod without dependencies offered some");
check(launcher_mods::orphaned_dependencies(packs, {"Me-Pack", "Team-A"}, all) == std::vector<std::string>{"Team-C"},
"A dependency removed already was offered again");
}
std::error_code ignored;
fs::remove_all(root, ignored);
if (failures) {
+4
View File
@@ -608,6 +608,10 @@ struct ModsPanel {
std::string message;
bool message_error{};
std::vector<std::string> confirm_remove; // folders awaiting "Uninstall" confirmation
// The mods those brought in that nothing else needs (launcher_mods::orphaned_dependencies),
// offered with them; worked out once for the folders in remove_extras_for.
std::vector<std::string> remove_extras, remove_extras_for;
bool remove_extras_on{true};
fs::path conflict_source; // install waiting for "Replace" confirmation
std::string conflict_name;
+30 -7
View File
@@ -1910,24 +1910,45 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
if (!panel.conflict_name.empty() && !ImGui::IsPopupOpen("Replace mod")) ImGui::OpenPopup("Replace mod");
ImGui::SetNextWindowSize(ImVec2(S(460), 0));
if (ImGui::BeginPopupModal(uninstall, nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
const auto names = panel.confirm_remove;
const auto chosen = panel.confirm_remove;
const auto title = [&](const std::string& name) -> const std::string& {
for (const auto& entry : entries)
if (entry.mod.name == name) return entry.mod.title;
return name;
};
// A mod pack's dependencies stay installed unless asked: offer the ones nothing else needs.
if (panel.remove_extras_for != chosen) {
std::vector<std::string> everything;
for (const auto& entry : entries) everything.push_back(entry.mod.name);
panel.remove_extras = launcher_mods::orphaned_dependencies(panel.root, chosen, everything);
panel.remove_extras_for = chosen;
panel.remove_extras_on = true;
}
auto names = chosen;
if (panel.remove_extras_on) names.insert(names.end(), panel.remove_extras.begin(), panel.remove_extras.end());
std::uint64_t bytes{};
for (const auto& name : names)
if (const auto found = panel.sizes.find(name); found != panel.sizes.end()) bytes += found->second;
ImGui::PushTextWrapPos(0);
if (names.size() == 1) {
ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", title(names.front()).c_str());
if (chosen.size() == 1) {
ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", title(chosen.front()).c_str());
} else {
ImGui::Text("Uninstall these %d mods? Their folders go to the Recycle Bin.", static_cast<int>(names.size()));
ImGui::Text("Uninstall these %d mods? Their folders go to the Recycle Bin.", static_cast<int>(chosen.size()));
ImGui::Spacing();
const std::size_t listed = std::min<std::size_t>(names.size(), 8);
for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(names[i]).c_str());
if (names.size() > listed) ImGui::TextDisabled("and %d more", static_cast<int>(names.size() - listed));
const std::size_t listed = std::min<std::size_t>(chosen.size(), 8);
for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(chosen[i]).c_str());
if (chosen.size() > listed) ImGui::TextDisabled("and %d more", static_cast<int>(chosen.size() - listed));
}
if (!panel.remove_extras.empty()) {
ImGui::Spacing();
const auto count = static_cast<int>(panel.remove_extras.size());
const auto label = count == 1 ? std::string("Also uninstall the mod it brought in, which nothing else needs")
: std::format("Also uninstall the {} mods it brought in, which nothing else needs", count);
ImGui::Checkbox(label.c_str(), &panel.remove_extras_on);
const std::size_t listed = std::min<std::size_t>(panel.remove_extras.size(), 8);
for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(panel.remove_extras[i]).c_str());
if (panel.remove_extras.size() > listed)
ImGui::TextDisabled("and %d more", static_cast<int>(panel.remove_extras.size() - listed));
}
if (bytes) ImGui::TextDisabled("That frees %s.", size_text(bytes).c_str());
ImGui::PopTextWrapPos();
@@ -1935,6 +1956,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
// Escape (and a controller's B) answers Cancel, never the destructive choice.
if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
panel.confirm_remove.clear();
panel.remove_extras_for.clear();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
@@ -1970,6 +1992,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo
panel.message = message;
panel.message_error = error;
panel.confirm_remove.clear();
panel.remove_extras_for.clear();
ImGui::CloseCurrentPopup();
}
pop_primary_button();
+73
View File
@@ -436,6 +436,79 @@ std::string install(const fs::path& mods, const fs::path& source, bool replace,
}
}
namespace {
// The mod folders `folder`'s manifest.json lists as dependencies, folded to lower case.
std::vector<std::string> listed_dependencies(const fs::path& folder) {
std::vector<std::string> result;
try {
std::string text;
{
std::ifstream input(folder / mods::manifest_file, std::ios::binary);
if (!input) return result;
text.assign(std::istreambuf_iterator<char>(input), {});
}
if (text.starts_with("\xef\xbb\xbf")) text.erase(0, 3);
const auto json = Json::parse(text, JsonLimits{1024 * 1024, 16, 65536});
if (!json.is_object() || !json.contains("dependencies") || !json.at("dependencies").is_array()) return result;
for (const auto& item : json.at("dependencies")) {
if (!item.is_string()) continue;
const auto& name = item.string();
// Namespace-Name-1.2.3: the folder is everything before the version.
const auto dash = name.rfind('-');
if (dash == std::string::npos || name.find('-') == dash) continue;
result.push_back(lower(std::string_view(name).substr(0, dash)));
}
} catch (...) {
result.clear();
}
return result;
}
}
std::vector<std::string> orphaned_dependencies(const fs::path& mods, const std::vector<std::string>& removing,
const std::vector<std::string>& installed) {
std::vector<std::string> folded;
for (const auto& name : installed) folded.push_back(lower(name));
const auto is_installed = [&](const std::string& name) { return std::find(folded.begin(), folded.end(), name) != folded.end(); };
std::vector<std::string> gone;
for (const auto& name : removing) gone.push_back(lower(name));
const auto listed = [&](const std::string& name) {
const auto at = std::find(folded.begin(), folded.end(), name);
return at == folded.end() ? std::vector<std::string>{} : listed_dependencies(mods / from_utf8(installed[at - folded.begin()]));
};
// Everything the removed mods bring in, through their dependencies' dependencies.
std::vector<std::string> candidates, queue = gone;
while (!queue.empty()) {
const auto name = queue.back();
queue.pop_back();
for (const auto& dependency : listed(name))
if (is_installed(dependency) && std::find(gone.begin(), gone.end(), dependency) == gone.end() &&
std::find(candidates.begin(), candidates.end(), dependency) == candidates.end()) {
candidates.push_back(dependency);
queue.push_back(dependency);
}
}
// What a mod that stays still lists stays too, with what that one lists in turn.
for (bool changed = true; changed;) {
changed = false;
for (std::size_t i = 0; i < folded.size(); ++i) {
const auto& name = folded[i];
if (std::find(gone.begin(), gone.end(), name) != gone.end() ||
std::find(candidates.begin(), candidates.end(), name) != candidates.end())
continue;
for (const auto& dependency : listed(name))
if (const auto at = std::find(candidates.begin(), candidates.end(), dependency); at != candidates.end()) {
candidates.erase(at);
changed = true;
}
}
}
std::vector<std::string> result;
for (std::size_t i = 0; i < folded.size(); ++i)
if (std::find(candidates.begin(), candidates.end(), folded[i]) != candidates.end()) result.push_back(installed[i]);
return result;
}
void remove(const fs::path& mods, const std::string& name) {
if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder.");
const auto target = mods / from_utf8(name);
+8
View File
@@ -53,5 +53,13 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste
// Moves Mods/<name> to the Recycle Bin. Throws a user-facing message.
void remove(const std::filesystem::path& mods_root, const std::string& name);
// The installed mods a pack brought in that nothing else needs, to offer with its uninstall:
// the folders the manifest.json of a mod in `removing` lists as dependencies ("Namespace-Name-1.2.3"
// is the folder Namespace-Name), and theirs in turn, without the ones `removing` already has and
// without any that a mod staying installed (the rest of `installed`) still lists. In `installed`'s
// order; an unreadable manifest lists nothing.
std::vector<std::string> orphaned_dependencies(const std::filesystem::path& mods_root,
const std::vector<std::string>& removing, const std::vector<std::string>& installed);
} // namespace dingosdk::launcher_mods