diff --git a/QuickShell.Core.Tests/StartupWarmupCoordinatorTests.cs b/QuickShell.Core.Tests/StartupWarmupCoordinatorTests.cs index 15e5b1e7..b815e2c9 100644 --- a/QuickShell.Core.Tests/StartupWarmupCoordinatorTests.cs +++ b/QuickShell.Core.Tests/StartupWarmupCoordinatorTests.cs @@ -400,7 +400,7 @@ private static QuickShellServices CreateServices( private static void WaitForCompletion(StartupWarmupCoordinator coordinator, TimeSpan? timeout = null) { - var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15)); while (!coordinator.IsCompleted && DateTime.UtcNow < deadline) { Thread.Sleep(20); diff --git a/QuickShell.Core/Classification/Suggestions/AgentCliSuggestionProvider.cs b/QuickShell.Core/Classification/Suggestions/AgentCliSuggestionProvider.cs index 1e57e248..0ae336bc 100644 --- a/QuickShell.Core/Classification/Suggestions/AgentCliSuggestionProvider.cs +++ b/QuickShell.Core/Classification/Suggestions/AgentCliSuggestionProvider.cs @@ -9,16 +9,16 @@ internal sealed class AgentCliSuggestionProvider : ITaskSuggestionProvider public IReadOnlyList GetSuggestions(TaskSuggestionContext context) { - var usedCommands = context.ExistingLaunches.Select(e => e.Command).Where(c => !string.IsNullOrWhiteSpace(c)).ToHashSet(StringComparer.OrdinalIgnoreCase); + var usedCommands = TaskTypePickContext.CreateUsedCommandSet(context.ExistingLaunches); + var pills = new List(); foreach (var def in AgentCliCatalog.Definitions) { - var detected = def.PathNames.FirstOrDefault(AgentCliCatalog.IsCommandOnPath); - if (detected is null && !AgentCliCatalog.HasProjectMarker(context.WorkspaceDirectory, def)) continue; - var cmd = detected ?? def.Command; - if (usedCommands.Contains(cmd) || usedCommands.Contains(def.Command)) continue; - var score = detected is not null ? AgentCliCatalog.PathDetectedScore : AgentCliCatalog.MarkerFallbackScore; - pills.Add(new CommandSuggestionPill(cmd, TaskTypeCatalog.Agent, "Agent", SuggestionPillPresentation.FormatDisplayTitle(cmd), SuggestionPillPresentation.FormatTooltip("Agent", cmd, productName: def.Title), score, detected is not null ? "agent-path" : "agent-marker")); + var pill = TryCreateSuggestionPill(def, context, usedCommands); + if (pill is not null) + { + pills.Add(pill); + } } // Do not Take() here: a provider-level cap hid the rest behind silent replacement @@ -28,4 +28,38 @@ public IReadOnlyList GetSuggestions(TaskSuggestionContext .ThenBy(p => p.DisplayTitle, StringComparer.OrdinalIgnoreCase) .ToList(); } + + private static CommandSuggestionPill? TryCreateSuggestionPill(AgentCliDefinition def, TaskSuggestionContext context, HashSet usedCommands) + { + string? detected = null; + foreach (var pathName in def.PathNames) + { + if (AgentCliCatalog.IsCommandOnPath(pathName)) + { + detected = pathName; + break; + } + } + + if (detected is null && !AgentCliCatalog.HasProjectMarker(context.WorkspaceDirectory, def)) + { + return null; + } + + var cmd = detected ?? def.Command; + if (usedCommands.Contains(cmd) || usedCommands.Contains(def.Command)) + { + return null; + } + + var score = detected is not null ? AgentCliCatalog.PathDetectedScore : AgentCliCatalog.MarkerFallbackScore; + return new CommandSuggestionPill( + cmd, + TaskTypeCatalog.Agent, + "Agent", + SuggestionPillPresentation.FormatDisplayTitle(cmd), + SuggestionPillPresentation.FormatTooltip("Agent", cmd, productName: def.Title), + score, + detected is not null ? "agent-path" : "agent-marker"); + } } diff --git a/QuickShell.Core/Services/TaskTypeCandidateBuilder.cs b/QuickShell.Core/Services/TaskTypeCandidateBuilder.cs index 047beaf2..7ccd78f9 100644 --- a/QuickShell.Core/Services/TaskTypeCandidateBuilder.cs +++ b/QuickShell.Core/Services/TaskTypeCandidateBuilder.cs @@ -478,7 +478,10 @@ internal static IReadOnlyList BuildPills(IEnumerable ?? tasks.ToList(); var suggestionContext = new SuggestionContext(context.WorkspaceDirectory, sourceTasks, context.ProjectClassification, context.ProjectAnalysis); - var pickContext = TaskTypePickContext.FromCommands(context.ExistingLaunches.Select(e => e.Command)); + var pickContext = new TaskTypePickContext + { + UsedCommands = TaskTypePickContext.CreateUsedCommandSet(context.ExistingLaunches), + }; var choices = TaskTypeCatalog.GetChoices(); var bestByCmd = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/QuickShell.Core/Services/TaskTypePickContext.cs b/QuickShell.Core/Services/TaskTypePickContext.cs index a87b24d8..78b83e35 100644 --- a/QuickShell.Core/Services/TaskTypePickContext.cs +++ b/QuickShell.Core/Services/TaskTypePickContext.cs @@ -1,3 +1,5 @@ +using QuickShell.Models; + namespace QuickShell.Services; internal sealed class TaskTypePickContext @@ -7,13 +9,44 @@ internal sealed class TaskTypePickContext public IReadOnlySet UsedCommands { get; init; } = EmptyUsedCommands.Instance; public static TaskTypePickContext FromCommands(IEnumerable commands) => - new() + new() { UsedCommands = CreateUsedCommandSet(commands) }; + + /// + /// Builds a case-insensitive set of non-blank commands for suggestion dedupe. + /// Shared by and agent/CLI suggestion providers. + /// + public static HashSet CreateUsedCommandSet(IEnumerable commands) + { + var usedCommands = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var command in commands) + { + if (!string.IsNullOrWhiteSpace(command)) + { + usedCommands.Add(command); + } + } + + return usedCommands; + } + + /// + /// Same as but walks launch + /// entries directly to avoid an intermediate Select iterator allocation. + /// + public static HashSet CreateUsedCommandSet(IReadOnlyList launches) + { + var usedCommands = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var launch in launches) { - UsedCommands = commands - .Where(command => !string.IsNullOrWhiteSpace(command)) - .Select(command => command!) - .ToHashSet(StringComparer.OrdinalIgnoreCase), - }; + var command = launch.Command; + if (!string.IsNullOrWhiteSpace(command)) + { + usedCommands.Add(command); + } + } + + return usedCommands; + } private sealed class EmptyUsedCommands : IReadOnlySet { diff --git a/QuickShell.Core/Services/WorkspaceSecurityPolicy.cs b/QuickShell.Core/Services/WorkspaceSecurityPolicy.cs index d29ccf40..48d1bc1d 100644 --- a/QuickShell.Core/Services/WorkspaceSecurityPolicy.cs +++ b/QuickShell.Core/Services/WorkspaceSecurityPolicy.cs @@ -81,6 +81,26 @@ internal sealed record TrustTransitionResult(TrustTransitionStatus Status, strin internal static class WorkspaceSecurityPolicy { + private static readonly WorkspaceIssueCode[] DefaultPrecedence = + [ + WorkspaceIssueCode.WorkspaceNotFound, + WorkspaceIssueCode.InvalidDirectory, + WorkspaceIssueCode.DirectoryMissing, + WorkspaceIssueCode.InvalidCommand, + WorkspaceIssueCode.InvalidLaunch, + WorkspaceIssueCode.InvalidUrl, + WorkspaceIssueCode.InvalidCompanion, + WorkspaceIssueCode.CompanionExecutableUnavailable, + WorkspaceIssueCode.WorkspaceUntrusted, + WorkspaceIssueCode.DirectoryOpenNotAllowed, + WorkspaceIssueCode.ActionNotAllowed, + ]; + + private static readonly WorkspaceIssueCode[] CopyPathPrecedence = + [ + WorkspaceIssueCode.InvalidDirectory, + ]; + public static WorkspaceAuthorizationResult NotFoundResult() => BuildResult( false, @@ -174,6 +194,22 @@ private static WorkspaceAuthorizationResult AuthorizeCore( break; } + AssessAdditionalRisks(content, action, risks); + ValidateDirectoryTrust(workspace, action, normalizedDirectory, issues); + + var primary = GetPrimaryIssue(issues, action); + var allowed = action switch + { + WorkspaceAction.CopyPath => !issues.Any(issue => issue.Code == WorkspaceIssueCode.InvalidDirectory), + WorkspaceAction.RevokeTrust => true, + WorkspaceAction.GrantTrust => issues.Count == 0, + _ => issues.Count == 0, + }; + return BuildResult(allowed, primary, issues, risks, normalizedDirectory, normalizedUrl, executablePath, arguments, content.Command, workspace.Revision); + } + + private static void AssessAdditionalRisks(TerminalShortcut content, WorkspaceAction action, List risks) + { var configuredCompanionCount = CompanionAppNormalization.GetConfigured(content).Count; if (configuredCompanionCount > 0 && action is not WorkspaceAction.StartCompanion and not WorkspaceAction.GrantTrust) { @@ -184,7 +220,10 @@ private static WorkspaceAuthorizationResult AuthorizeCore( { risks.Add(new("dev-server", "This workspace opens a configured URL after launch.")); } + } + private static void ValidateDirectoryTrust(StoredWorkspace workspace, WorkspaceAction action, string? normalizedDirectory, List issues) + { if (WorkspaceTrustFeatures.Enabled && !workspace.Security.IsTrusted && RequiresTrust(action)) { issues.Add(new(WorkspaceIssueCode.WorkspaceUntrusted, "Trust this workspace before starting external processes or opening it.")); @@ -201,16 +240,6 @@ private static WorkspaceAuthorizationResult AuthorizeCore( issues.Add(new(WorkspaceIssueCode.DirectoryOpenNotAllowed, "Only existing rooted local drive directories can be opened in Explorer.")); } } - - var primary = GetPrimaryIssue(issues, action); - var allowed = action switch - { - WorkspaceAction.CopyPath => !issues.Any(issue => issue.Code == WorkspaceIssueCode.InvalidDirectory), - WorkspaceAction.RevokeTrust => true, - WorkspaceAction.GrantTrust => issues.Count == 0, - _ => issues.Count == 0, - }; - return BuildResult(allowed, primary, issues, risks, normalizedDirectory, normalizedUrl, executablePath, arguments, content.Command, workspace.Revision); } private static bool RequiresDirectory(WorkspaceAction action) => @@ -369,6 +398,13 @@ public static WorkspaceAuthorizationResult AuthorizeUrl( action); } + /// + /// Picks the highest-precedence issue for messaging. Returns null when + /// there are no issues, or when none of the issues appear in the action's + /// precedence list. Callers must treat + /// as optional; authorization itself is driven by + /// and the full Issues list, not by a fake enum-zero sentinel. + /// private static WorkspaceIssueCode? GetPrimaryIssue( List issues, WorkspaceAction action) @@ -379,24 +415,22 @@ public static WorkspaceAuthorizationResult AuthorizeUrl( } var precedence = action == WorkspaceAction.CopyPath - ? new[] { WorkspaceIssueCode.InvalidDirectory } - : new[] + ? CopyPathPrecedence + : DefaultPrecedence; + + // Allocation-free nested scan; lists are small (a few issue codes). + foreach (var code in precedence) + { + foreach (var issue in issues) { - WorkspaceIssueCode.WorkspaceNotFound, - WorkspaceIssueCode.InvalidDirectory, - WorkspaceIssueCode.DirectoryMissing, - WorkspaceIssueCode.InvalidCommand, - WorkspaceIssueCode.InvalidLaunch, - WorkspaceIssueCode.InvalidUrl, - WorkspaceIssueCode.InvalidCompanion, - WorkspaceIssueCode.CompanionExecutableUnavailable, - WorkspaceIssueCode.WorkspaceUntrusted, - WorkspaceIssueCode.DirectoryOpenNotAllowed, - WorkspaceIssueCode.ActionNotAllowed, - }; - - var issueCodes = issues.Select(issue => issue.Code).ToHashSet(); - return precedence.FirstOrDefault(issueCodes.Contains); + if (issue.Code == code) + { + return code; + } + } + } + + return null; } public static WorkspaceReviewToken CreateReviewToken(StoredWorkspace workspace) =>