diff --git a/Dalamud/Interface/Internal/PluginCategoryManager.cs b/Dalamud/Interface/Internal/PluginCategoryManager.cs index 2b7f0f354..9fdcfe4c1 100644 --- a/Dalamud/Interface/Internal/PluginCategoryManager.cs +++ b/Dalamud/Interface/Internal/PluginCategoryManager.cs @@ -31,6 +31,9 @@ internal class PluginCategoryManager new(CategoryKind.PluginChangelogs, "special.plugins", () => Locs.Category_Plugins), new(CategoryKind.PluginProfiles, "special.profiles", () => Locs.Category_PluginProfiles), new(CategoryKind.UpdateablePlugins, "special.updateable", () => Locs.Category_UpdateablePlugins), + new(CategoryKind.EnabledPlugins, "special.enabled", () => Locs.Category_EnabledPlugins), + new(CategoryKind.DisabledPlugins, "special.disabled", () => Locs.Category_DisabledPlugins), + new(CategoryKind.IncompatiblePlugins, "special.incompatible", () => Locs.Category_IncompatiblePlugins), // Tag-driven categories new(CategoryKind.Other, "other", () => Locs.Category_Other), @@ -48,7 +51,7 @@ internal class PluginCategoryManager private GroupInfo[] groupList = [ new(GroupKind.DevTools, () => Locs.Group_DevTools, CategoryKind.DevInstalled, CategoryKind.IconTester), - new(GroupKind.Installed, () => Locs.Group_Installed, CategoryKind.All, CategoryKind.IsTesting, CategoryKind.UpdateablePlugins, CategoryKind.PluginProfiles), + new(GroupKind.Installed, () => Locs.Group_Installed, CategoryKind.All, CategoryKind.IsTesting, CategoryKind.UpdateablePlugins, CategoryKind.EnabledPlugins, CategoryKind.DisabledPlugins, CategoryKind.IncompatiblePlugins, CategoryKind.PluginProfiles), new(GroupKind.Available, () => Locs.Group_Available, CategoryKind.All), new(GroupKind.Changelog, () => Locs.Group_Changelog, CategoryKind.All, CategoryKind.DalamudChangelogs, CategoryKind.PluginChangelogs) @@ -143,6 +146,21 @@ internal class PluginCategoryManager /// UpdateablePlugins = 15, + /// + /// Enabled plugins. + /// + EnabledPlugins = 16, + + /// + /// Disabled plugins. + /// + DisabledPlugins = 17, + + /// + /// Incompatible plugins. + /// + IncompatiblePlugins = 18, + /// /// Plugins tagged as "other". /// @@ -555,6 +573,12 @@ internal class PluginCategoryManager public static string Category_PluginProfiles => Loc.Localize("InstallerCategoryPluginProfiles", "Plugin Collections"); public static string Category_UpdateablePlugins => Loc.Localize("InstallerCategoryCanBeUpdated", "Can be updated"); + + public static string Category_EnabledPlugins => Loc.Localize("InstallerCategoryEnabledPlugins", "Enabled"); + + public static string Category_DisabledPlugins => Loc.Localize("InstallerCategoryDisabledPlugins", "Disabled"); + + public static string Category_IncompatiblePlugins => Loc.Localize("InstallerCategoryIncompatiblePlugins", "Incompatible"); public static string Category_Other => Loc.Localize("InstallerCategoryOther", "Other"); diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index d132f0d8d..a6845d4ae 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -127,7 +127,27 @@ internal class PluginInstallerWindow : Window, IDisposable private string filterText = Locs.SortBy_Alphabetical; private bool adaptiveSort = true; - private OperationStatus installStatus = OperationStatus.Idle; + private string? selectedRepoUrl = null; // null = All Repositories + private string selectedRepoUrlNormalized = string.Empty; + private int repoFilterCacheKey = 0; + + private readonly List cachedRepoUrls = new(); + private readonly HashSet cachedRepoUrlsNormalized = new(StringComparer.OrdinalIgnoreCase); + private const string XivLauncherRepoKey = "XIVLauncher"; + + private void SetSelectedRepo(string? repoUrl) + { + if (this.selectedRepoUrl == repoUrl) + return; + + this.selectedRepoUrl = repoUrl; + this.selectedRepoUrlNormalized = NormalizeRepoUrl(repoUrl); + + this.UpdateCategoriesOnPluginsChange(); + this.openPluginCollapsibles.Clear(); + } + +private OperationStatus installStatus = OperationStatus.Idle; private OperationStatus updateStatus = OperationStatus.Idle; private OperationStatus enableDisableStatus = OperationStatus.Idle; @@ -236,6 +256,9 @@ internal class PluginInstallerWindow : Window, IDisposable Testing, Updateable, Dev, + Enabled, + Disabled, + Incompatible, } private bool AnyOperationInProgress => this.installStatus == OperationStatus.InProgress || @@ -787,7 +810,62 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.EndCombo(); } } - } + + // Repository filter (overarching filter for repositories) + this.RefreshRepoFilterList(); + + // Disabled for dev plugin list, changelog views, or profile editor (plugin collections). + var disableRepoFilter = + isProfileManager || + this.categoryManager.CurrentGroupKind is + PluginCategoryManager.GroupKind.Changelog or + PluginCategoryManager.GroupKind.DevTools; + + using (ImRaii.Disabled(disableRepoFilter)) + { + ImGuiHelpers.ScaledDummy(5); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Repository Filter"); + ImGui.SameLine(); + + // Fill remaining width of the header row + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + + var repoPreview = this.selectedRepoUrl ?? "All Repositories"; + if (ImGui.BeginCombo("##RepoFilterCombo", repoPreview)) + { + if (ImGui.Selectable("All Repositories", this.selectedRepoUrl == null)) + { + this.SetSelectedRepo(null); +} + + if (ImGui.Selectable("XIVLauncher", this.selectedRepoUrl == XivLauncherRepoKey)) + { + this.SetSelectedRepo(XivLauncherRepoKey); +} + + ImGui.Separator(); + + for (var i = 0; i < this.cachedRepoUrls.Count; i++) + { + var repoUrl = this.cachedRepoUrls[i]; + var selected = this.selectedRepoUrl == repoUrl; + + // Use stable unique IDs to avoid ImGui collisions with long/duplicate URLs. + if (ImGui.Selectable($"{repoUrl}##repo_{i}", selected)) + { + this.SetSelectedRepo(repoUrl); +} + } + + ImGui.EndCombo(); + } + + ImGuiHelpers.ScaledDummy(10); + } + +} private void DrawFooter() { @@ -1304,6 +1382,7 @@ internal class PluginInstallerWindow : Window, IDisposable var filteredAvailableManifests = availableManifests .Where(rm => !this.IsManifestFiltered(rm)) + .Where(this.PassesRepoFilter) .ToList(); if (filteredAvailableManifests.Count == 0) @@ -1316,7 +1395,7 @@ internal class PluginInstallerWindow : Window, IDisposable { var plugin = this.pluginListInstalled .FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName && - plugin.Manifest.RepoUrl == availableManifest.RepoUrl && + RepoUrlMatches(GetRepoFilterUrl(plugin), GetRepoFilterUrl(availableManifest)) && !plugin.IsDev); // We "consumed" this plugin from the pile and remove it. @@ -1337,6 +1416,9 @@ internal class PluginInstallerWindow : Window, IDisposable if (this.IsManifestFiltered(installedPlugin.Manifest)) continue; + if (!this.PassesRepoFilter(GetRepoFilterUrl(installedPlugin))) + continue; + // TODO: We should also check categories here, for good measure proxies.Add(new PluginInstallerAvailablePluginProxy(null, installedPlugin)); @@ -1439,6 +1521,7 @@ internal class PluginInstallerWindow : Window, IDisposable var filteredList = pluginList .Where(plugin => !this.IsManifestFiltered(plugin.Manifest)) + .Where(plugin => this.PassesRepoFilter(!string.IsNullOrWhiteSpace(plugin.Manifest.InstalledFromUrl) ? plugin.Manifest.InstalledFromUrl : plugin.Manifest.RepoUrl)) .ToList(); if (filteredList.Count == 0) @@ -1454,6 +1537,15 @@ internal class PluginInstallerWindow : Window, IDisposable if (filter == InstalledPluginListFilter.Testing && !manager.HasTestingOptIn(plugin.Manifest)) continue; + if (filter == InstalledPluginListFilter.Enabled && (!plugin.IsWantedByAnyProfile || plugin.IsOutdated || plugin.IsBanned || plugin.IsOrphaned || plugin.IsDecommissioned)) + continue; + + if (filter == InstalledPluginListFilter.Disabled && (plugin.IsWantedByAnyProfile || plugin.IsOutdated || plugin.IsBanned || plugin.IsOrphaned || plugin.IsDecommissioned)) + continue; + + if (filter == InstalledPluginListFilter.Incompatible && !(plugin.IsOutdated || plugin.IsBanned || plugin.IsOrphaned || plugin.IsDecommissioned)) + continue; + // Find applicable update and manifest, if we have them AvailablePluginUpdate? update = null; RemotePluginManifest? remoteManifest = null; @@ -1467,7 +1559,7 @@ internal class PluginInstallerWindow : Window, IDisposable // Find the applicable remote manifest remoteManifest = this.pluginListAvailable .FirstOrDefault(rm => rm.InternalName == plugin.Manifest.InternalName && - rm.RepoUrl == plugin.Manifest.RepoUrl); + RepoUrlMatches(rm.RepoUrl, plugin.Manifest.RepoUrl)); } else if (!plugin.IsDev) { @@ -1486,6 +1578,9 @@ internal class PluginInstallerWindow : Window, IDisposable InstalledPluginListFilter.Testing => Locs.TabBody_NoPluginsTesting, InstalledPluginListFilter.Updateable => Locs.TabBody_NoPluginsUpdateable, InstalledPluginListFilter.Dev => Locs.TabBody_NoPluginsDev, + InstalledPluginListFilter.Enabled => Locs.TabBody_NoPluginsEnabled, + InstalledPluginListFilter.Disabled => Locs.TabBody_NoPluginsDisabled, + InstalledPluginListFilter.Incompatible => Locs.TabBody_NoPluginsIncompatible, _ => throw new ArgumentException(null, nameof(filter)), }; @@ -1726,6 +1821,18 @@ internal class PluginInstallerWindow : Window, IDisposable this.DrawInstalledPluginList(InstalledPluginListFilter.Updateable); break; + case PluginCategoryManager.CategoryKind.EnabledPlugins: + this.DrawInstalledPluginList(InstalledPluginListFilter.Enabled); + break; + + case PluginCategoryManager.CategoryKind.DisabledPlugins: + this.DrawInstalledPluginList(InstalledPluginListFilter.Disabled); + break; + + case PluginCategoryManager.CategoryKind.IncompatiblePlugins: + this.DrawInstalledPluginList(InstalledPluginListFilter.Incompatible); + break; + case PluginCategoryManager.CategoryKind.PluginProfiles: this.profileManagerWidget.Draw(); break; @@ -3950,12 +4057,12 @@ internal class PluginInstallerWindow : Window, IDisposable } else { - var pluginsMatchingSearch = this.pluginListAvailable.Where(rm => !this.IsManifestFiltered(rm)).ToArray(); + var pluginsMatchingSearch = this.pluginListAvailable.Where(rm => this.PassesRepoFilter(rm) && !this.IsManifestFiltered(rm)).ToArray(); // Check if the search results are different, and clear the open collapsibles if they are if (previousSearchText != null) { - var previousSearchResults = this.pluginListAvailable.Where(rm => !this.IsManifestFiltered(rm)).ToArray(); + var previousSearchResults = this.pluginListAvailable.Where(rm => this.PassesRepoFilter(rm) && !this.IsManifestFiltered(rm)).ToArray(); if (!previousSearchResults.SequenceEqual(pluginsMatchingSearch)) this.openPluginCollapsibles.Clear(); } @@ -3966,11 +4073,142 @@ internal class PluginInstallerWindow : Window, IDisposable private void UpdateCategoriesOnPluginsChange() { - this.categoryManager.BuildCategories(this.pluginListAvailable); + this.categoryManager.BuildCategories(this.pluginListAvailable.Where(this.PassesRepoFilter).ToList()); this.UpdateCategoriesOnSearchChange(null); } - private void DrawFontawesomeIconOutlined(FontAwesomeIcon icon, Vector4 outline, Vector4 iconColor) + private void RefreshRepoFilterList() + { + var config = Service.Get(); + + // Avoid rebuilding every frame unless the repo list actually changed. + var newKey = 17; + unchecked + { + foreach (var repo in config.ThirdRepoList) + { + if (string.IsNullOrWhiteSpace(repo.Url)) + continue; + + newKey = (newKey * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(repo.Url.Trim()); + } + } + + if (newKey == this.repoFilterCacheKey) + return; + + this.repoFilterCacheKey = newKey; + +this.cachedRepoUrls.Clear(); + this.cachedRepoUrlsNormalized.Clear(); + + // Main repo is represented by the "XIVLauncher" entry; here we only list third-party repos. + var seenDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var repo in config.ThirdRepoList) + { + if (string.IsNullOrWhiteSpace(repo.Url)) + continue; + + var trimmed = repo.Url.Trim(); + if (!seenDisplay.Add(trimmed)) + continue; + + this.cachedRepoUrls.Add(trimmed); + + var normalized = NormalizeRepoUrl(trimmed); + if (!string.IsNullOrEmpty(normalized)) + this.cachedRepoUrlsNormalized.Add(normalized); + } + } + + + private static string NormalizeRepoUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + return string.Empty; + + url = url.Trim(); + + // Best-effort URI normalization: ignore query/fragment; compare by path. + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + var left = uri.GetLeftPart(UriPartial.Path); + return left.TrimEnd('/'); + } + + return url.TrimEnd('/'); + } + + private static bool RepoUrlMatches(string? a, string? b) + { + var na = NormalizeRepoUrl(a); + var nb = NormalizeRepoUrl(b); + + if (string.IsNullOrEmpty(na) || string.IsNullOrEmpty(nb)) + return false; + + if (string.Equals(na, nb, StringComparison.OrdinalIgnoreCase)) + return true; + + // Allow prefix matches to handle minor formatting differences (e.g. /api/6 suffix). + // Ensure the prefix boundary is a path separator. + if (na.Length < nb.Length && nb.StartsWith(na, StringComparison.OrdinalIgnoreCase)) + return nb[na.Length] == '/'; + + if (nb.Length < na.Length && na.StartsWith(nb, StringComparison.OrdinalIgnoreCase)) + return na[nb.Length] == '/'; + + return false; + } + + +private static string? GetRepoFilterUrl(RemotePluginManifest manifest) +{ + // For available manifests, RepoUrl is often the plugin's project URL (or null). + // The repository identity we care about is the *source repo* (pluginmaster URL) vs XIVLauncher (main repo). + if (manifest.SourceRepo != null && manifest.SourceRepo.IsThirdParty) + return manifest.SourceRepo.PluginMasterUrl; + + return PluginRepository.MainRepoUrl; +} + +private static string? GetRepoFilterUrl(LocalPlugin plugin) +{ + // Installed third-party plugins store their origin in InstalledFromUrl. + if (plugin.IsThirdParty) + return plugin.Manifest.InstalledFromUrl; + + return PluginRepository.MainRepoUrl; +} + + private bool PassesRepoFilter(string? repoUrl) + { + // null => All Repositories + if (this.selectedRepoUrl == null) + return true; + + // Anything not from the user's third-party repo list + if (this.selectedRepoUrl == XivLauncherRepoKey) + { + // Treat empty and the known main repo URL as "XIVLauncher" + if (string.IsNullOrWhiteSpace(repoUrl) || RepoUrlMatches(repoUrl, PluginRepository.MainRepoUrl)) + return true; + + var normalized = NormalizeRepoUrl(repoUrl); + if (string.IsNullOrEmpty(normalized)) + return true; + + // Anything not matching the configured third-party repos is considered "XIVLauncher". + return !this.cachedRepoUrlsNormalized.Contains(normalized); + } + + return RepoUrlMatches(repoUrl, this.selectedRepoUrlNormalized); + } + + private bool PassesRepoFilter(RemotePluginManifest manifest) => this.PassesRepoFilter(GetRepoFilterUrl(manifest)); + +private void DrawFontawesomeIconOutlined(FontAwesomeIcon icon, Vector4 outline, Vector4 iconColor) { var positionOffset = ImGuiHelpers.ScaledVector2(0.0f, 1.0f); var cursorStart = ImGui.GetCursorPos() + positionOffset; @@ -4098,6 +4336,12 @@ internal class PluginInstallerWindow : Window, IDisposable public static string TabBody_NoPluginsDev => Loc.Localize("InstallerNoPluginsDev", "You don't have any dev plugins. Add them from the settings."); + public static string TabBody_NoPluginsEnabled => Loc.Localize("InstallerNoPluginsEnabled", "You don't have any enabled plugins."); + + public static string TabBody_NoPluginsDisabled => Loc.Localize("InstallerNoPluginsDisabled", "You don't have any disabled plugins."); + + public static string TabBody_NoPluginsIncompatible => Loc.Localize("InstallerNoPluginsIncompatible", "You don't have any incompatible plugins."); + #endregion #region Search text