diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index 72fed787d..8fe304fab 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -126,6 +126,11 @@ internal class PluginInstallerWindow : Window, IDisposable private string filterText = Locs.SortBy_Alphabetical; private bool adaptiveSort = true; + private string? selectedRepoUrl = null; // null = All Repositories + private List cachedRepoUrls = new(); + private HashSet cachedRepoUrlsNormalized = new(StringComparer.OrdinalIgnoreCase); + private const string XivLauncherRepoKey = "XIVLauncher"; + private OperationStatus installStatus = OperationStatus.Idle; private OperationStatus updateStatus = OperationStatus.Idle; @@ -789,7 +794,64 @@ internal class PluginInstallerWindow : Window, IDisposable ImGui.EndCombo(); } } - } + + // Repository filter (overarching filter for repositories) + this.RefreshRepoFilterList(); + + // Disabled for changelog views or profile editor (plugin collections). + var disableRepoFilter = this.categoryManager.CurrentGroupKind == PluginCategoryManager.GroupKind.Changelog || isProfileManager; + + 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.selectedRepoUrl = null; + this.UpdateCategoriesOnPluginsChange(); + this.openPluginCollapsibles.Clear(); + } + + if (ImGui.Selectable("XIVLauncher", this.selectedRepoUrl == XivLauncherRepoKey)) + { + this.selectedRepoUrl = XivLauncherRepoKey; + this.UpdateCategoriesOnPluginsChange(); + this.openPluginCollapsibles.Clear(); + } + + 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.selectedRepoUrl = repoUrl; + this.UpdateCategoriesOnPluginsChange(); + this.openPluginCollapsibles.Clear(); + } + } + + ImGui.EndCombo(); + } + + ImGuiHelpers.ScaledDummy(10); + } + +} private void DrawFooter() { @@ -1306,6 +1368,7 @@ internal class PluginInstallerWindow : Window, IDisposable var filteredAvailableManifests = availableManifests .Where(rm => !this.IsManifestFiltered(rm)) + .Where(this.PassesRepoFilter) .ToList(); if (filteredAvailableManifests.Count == 0) @@ -1318,7 +1381,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. @@ -1339,6 +1402,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)); @@ -1441,6 +1507,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) @@ -1478,7 +1545,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) { @@ -3976,12 +4043,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(); } @@ -3992,10 +4059,123 @@ 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 RefreshRepoFilterList() + { + var config = Service.Get(); + + 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.Any(u => RepoUrlMatches(u, normalized) || string.Equals(u, normalized, StringComparison.OrdinalIgnoreCase)); + } + + return RepoUrlMatches(repoUrl, this.selectedRepoUrl); + } + + 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);