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