diff --git a/src/components/app/CommandPalette.svelte b/src/components/app/CommandPalette.svelte new file mode 100644 index 0000000000..cab20854fb --- /dev/null +++ b/src/components/app/CommandPalette.svelte @@ -0,0 +1,166 @@ + + + + + + + + + + No commands found. + {#each [...groupedCommands] as [category, commands]} + + {#each commands as command} + handleSelect(command)} disabled={!command.enabled}> + {command.label} + {#if command.disabledReason} +
{command.disabledReason}
+ {:else if command.shortcut} + + {command.shortcut()} + + {/if} +
+ {/each} +
+ {/each} +
+
+ + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bbc137ee56..3b85bd4645 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,11 +7,16 @@ import WarningIcon from '@nasa-jpl/stellar/icons/warning.svg?component'; import { mergeWith } from 'lodash-es'; import { onMount } from 'svelte'; + import CommandPalette from '../components/app/CommandPalette.svelte'; import Nav from '../components/app/Nav.svelte'; import Loading from '../components/Loading.svelte'; + import { closeCommandPalette } from '../stores/commandPalette'; import { clearLogs } from '../stores/errors'; import { plugins, pluginsError, pluginsLoaded } from '../stores/plugins'; import { loadPluginCode } from '../utilities/plugins'; + import type { LayoutData } from './$types'; + + export let data: LayoutData; let pluginsEnabled = env.PUBLIC_TIME_PLUGIN_ENABLED === 'true'; $pluginsLoaded = pluginsEnabled ? false : true; @@ -25,6 +30,7 @@ beforeNavigate(() => { // Clear logs on page change clearLogs(); + closeCommandPalette(); }); async function loadPlugins() { @@ -61,6 +67,7 @@ {/if} +
diff --git a/src/stores/commandPalette.ts b/src/stores/commandPalette.ts new file mode 100644 index 0000000000..404c85b66b --- /dev/null +++ b/src/stores/commandPalette.ts @@ -0,0 +1,40 @@ +import { writable } from 'svelte/store'; +import type { CommandContext } from '../types/command-palette'; + +/** + * Store to control the command palette visibility + */ +export const commandPaletteOpen = writable(false); + +/** + * Store for the current command context + * This is updated by the CommandPaletteProvider component + */ +export const commandPaletteContext = writable({ + model: null, + plan: null, + route: '', + user: null, + workspace: null, +}); + +/** + * Open the command palette + */ +export function openCommandPalette(): void { + commandPaletteOpen.set(true); +} + +/** + * Close the command palette + */ +export function closeCommandPalette(): void { + commandPaletteOpen.set(false); +} + +/** + * Toggle the command palette + */ +export function toggleCommandPalette(): void { + commandPaletteOpen.update(open => !open); +} diff --git a/src/stores/plan.ts b/src/stores/plan.ts index b29f84709d..48c4c93646 100644 --- a/src/stores/plan.ts +++ b/src/stores/plan.ts @@ -113,7 +113,7 @@ export const planRevision = gqlSubscribable( { planId }, -1, null, - ({ revision }: Pick) => revision, + plan => plan?.revision ?? -1, ); /* Helper Functions. */ diff --git a/src/stores/scheduling.ts b/src/stores/scheduling.ts index 0b6e389bdf..9eda5ebd86 100644 --- a/src/stores/scheduling.ts +++ b/src/stores/scheduling.ts @@ -31,7 +31,7 @@ export const schedulingColumns: Writable = writable('1fr 3px 1fr'); /* Derived. */ -export const selectedSchedulingSpecId = derived(plan, $plan => $plan?.scheduling_specification?.id ?? null); +export const selectedSchedulingSpecId = derived(plan, $plan => $plan?.scheduling_specification?.id ?? -1); /* Subscriptions. */ diff --git a/src/types/command-palette.ts b/src/types/command-palette.ts new file mode 100644 index 0000000000..203877ecb2 --- /dev/null +++ b/src/types/command-palette.ts @@ -0,0 +1,104 @@ +import type { Status } from '../enums/status'; +import type { User } from './app'; +import type { ModelWithOwner, PlanWithOwners } from './permissions'; +import type { Plan } from './plan'; +import type { Workspace } from './workspace'; + +/** + * Context available to commands for permission checks and execution. + * This context is built from current application state (stores, route, etc.) + * + * IMPORTANT: All values that affect command enabled state must be included here + * to ensure proper reactivity. Do not use get() to read stores inside commands - + * instead, add the store value to this context. + */ +export interface CommandContext { + /** Current constraint check status */ + constraintsStatus: Status | null; + /** Whether scheduling can be run (goals are enabled) */ + enableScheduling: boolean; + /** Whether simulation can be run (plan has changes) */ + enableSimulation: boolean; + /** Full plan object for effects that need it (e.g., simulation, scheduling) */ + fullPlan: Plan | null; + /** Current model (if on a model-specific page) */ + model?: ModelWithOwner | null; + /** Current plan (minimal info for permission checks) */ + plan?: PlanWithOwners | null; + /** Whether the current plan is read-only */ + planReadOnly: boolean; + /** Current route pathname */ + route: string; + /** Current simulation status */ + simulationStatus: Status | null; + /** Current authenticated user */ + user: User | null; + /** Current workspace (if in workspace context) */ + workspace?: Workspace | null; +} + +/** + * Categories for organizing commands in the palette UI. + */ +export type CommandCategory = + | 'Activity' + | 'Constraint' + | 'Expansion' + | 'External Source' + | 'Model' + | 'Navigation' + | 'Plan' + | 'Scheduling' + | 'Simulation' + | 'View' + | 'Workspace'; + +/** + * A command that can be executed from the command palette. + * Commands wrap existing effects functions with metadata for display and permission handling. + */ +export interface Command { + /** Category for grouping commands */ + category: CommandCategory; + + /** + * Execute the command. This typically calls an effect function. + */ + execute: (context: CommandContext) => Promise; + + /** + * Returns the reason why the command is disabled, or null if enabled. + * This is the single source of truth for enabled state - if this returns null, + * the command is enabled. If it returns a string, the command is disabled + * and the string explains why. + */ + getDisabledReason: (context: CommandContext) => string | null; + + /** Unique identifier for the command */ + id: string; + + /** + * Whether this command is available in the current context. + * Use this for page-specific commands (e.g., "Run Simulation" only on plan pages). + * Returns true if the command should appear in the palette. + */ + isAvailable: (context: CommandContext) => boolean; + + /** Optional keywords for fuzzy search (beyond the label) */ + keywords?: string[]; + + /** Display label shown in the palette */ + label: string; + + /** Optional keyboard shortcut hint to display */ + shortcut?: () => string; +} + +/** + * A command with computed enabled state and disabled reason. + * Used by the UI after filtering and processing commands. + */ +export interface ProcessedCommand extends Command { + disabledReason: string | null; + enabled: boolean; +} diff --git a/src/utilities/commandRegistry.ts b/src/utilities/commandRegistry.ts new file mode 100644 index 0000000000..2e0318fa6f --- /dev/null +++ b/src/utilities/commandRegistry.ts @@ -0,0 +1,441 @@ +/** + * Command Registry for the Command Palette + * + * This module provides a centralized registry of commands that can be executed + * from the command palette. Commands focus on navigation and actions that can be + * performed without complex parameters. + * + * For actions that require user input (like creating entities), commands navigate + * to the appropriate page where the user can complete the action. + * + * To add a new command: + * 1. Import the necessary permission check from './permissions' + * 2. Add a new Command object to the `commands` array + * 3. Use existing `featurePermissions` for `isEnabled` to avoid duplication + * 4. Use `isAvailable` to restrict commands to specific pages/contexts + */ + +import { goto } from '$app/navigation'; +import { base } from '$app/paths'; +import { Status } from '../enums/status'; +import type { Command, CommandContext, ProcessedCommand } from '../types/command-palette'; +import effects from './effects'; +import { isMacOs } from './generic'; +import { featurePermissions } from './permissions'; + +// Route patterns for context detection +const PLAN_ROUTES = /\/plans\/\d+/; + +/** + * Helper to check if route matches a pattern + */ +function matchesRoute(route: string, pattern: RegExp): boolean { + return pattern.test(route); +} + +/** + * All registered commands. + * Commands are organized by category for easier maintenance. + */ +export const commands: Command[] = [ + // ============================================ + // NAVIGATION COMMANDS + // ============================================ + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/plans`); + }, + getDisabledReason: () => null, + id: 'nav.plans', + isAvailable: () => true, + keywords: ['navigate', 'list', 'open'], + label: 'Go to Plans', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/models`); + }, + getDisabledReason: () => null, + id: 'nav.models', + isAvailable: () => true, + keywords: ['navigate', 'mission', 'open'], + label: 'Go to Models', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/constraints`); + }, + getDisabledReason: () => null, + id: 'nav.constraints', + isAvailable: () => true, + keywords: ['navigate', 'open'], + label: 'Go to Constraints', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/scheduling`); + }, + getDisabledReason: () => null, + id: 'nav.scheduling', + isAvailable: () => true, + keywords: ['navigate', 'goals', 'conditions', 'open'], + label: 'Go to Scheduling', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/expansion/rules`); + }, + getDisabledReason: () => null, + id: 'nav.expansion', + isAvailable: () => true, + keywords: ['navigate', 'rules', 'sets', 'open'], + label: 'Go to Expansion', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/workspaces`); + }, + getDisabledReason: () => null, + id: 'nav.workspaces', + isAvailable: () => true, + keywords: ['navigate', 'open'], + label: 'Go to Workspaces', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/external-sources/sources`); + }, + getDisabledReason: () => null, + id: 'nav.externalSources', + isAvailable: () => true, + keywords: ['navigate', 'events', 'open'], + label: 'Go to External Sources', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/dictionaries`); + }, + getDisabledReason: () => null, + id: 'nav.dictionaries', + isAvailable: () => true, + keywords: ['navigate', 'command', 'channel', 'open'], + label: 'Go to Dictionaries', + }, + + // ============================================ + // PLAN COMMANDS + // ============================================ + { + category: 'Plan', + execute: async ({ fullPlan, user }) => { + if (fullPlan) { + await effects.createPlanBranch(fullPlan, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (!featurePermissions.planBranch.canCreateBranch(user, plan, model)) { + return 'You do not have permission to create a plan branch'; + } + return null; + }, + id: 'plan.createBranch', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['copy', 'branch', 'clone'], + label: 'Create Plan Branch', + }, + { + category: 'Plan', + execute: async ({ fullPlan, user }) => { + if (fullPlan) { + await effects.createPlanSnapshot(fullPlan, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (!featurePermissions.planSnapshot.canCreate(user, plan, model)) { + return 'You do not have permission to create snapshots'; + } + return null; + }, + id: 'plan.createSnapshot', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['save', 'backup', 'version'], + label: 'Create Plan Snapshot', + }, + + // ============================================ + // SIMULATION COMMANDS + // ============================================ + { + category: 'Simulation', + execute: async ({ fullPlan, user }) => { + if (fullPlan) { + await effects.simulate(fullPlan, false, user); + } + }, + getDisabledReason: ({ enableSimulation, model, plan, planReadOnly, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (planReadOnly) { + return 'Plan is read-only'; + } + if (!featurePermissions.simulation.canRun(user, plan, model)) { + return 'You do not have permission to run simulations'; + } + if (!enableSimulation) { + return 'Simulation up-to-date'; + } + return null; + }, + id: 'simulation.run', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['simulate', 'execute', 'start'], + label: 'Run Simulation', + shortcut: () => `${isMacOs() ? '⌘' : 'CTRL'}S`, + }, + + // ============================================ + // SCHEDULING COMMANDS + // ============================================ + { + category: 'Scheduling', + execute: async ({ fullPlan, user }) => { + if (fullPlan) { + await effects.schedule(false, fullPlan, user); + } + }, + getDisabledReason: ({ enableScheduling, model, plan, planReadOnly, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (planReadOnly) { + return 'Plan is read-only'; + } + if (!featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model)) { + return 'You do not have permission to run scheduling'; + } + if (!enableScheduling) { + return 'No scheduling goals enabled'; + } + return null; + }, + id: 'scheduling.run', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['schedule', 'goals', 'execute'], + label: 'Run Scheduling', + }, + { + category: 'Scheduling', + execute: async ({ fullPlan, user }) => { + if (fullPlan) { + await effects.schedule(true, fullPlan, user); + } + }, + getDisabledReason: ({ enableScheduling, model, plan, planReadOnly, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (planReadOnly) { + return 'Plan is read-only'; + } + if (!featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model)) { + return 'You do not have permission to run scheduling'; + } + if (!enableScheduling) { + return 'No scheduling goals enabled'; + } + return null; + }, + id: 'scheduling.analyze', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['schedule', 'goals', 'analyze'], + label: 'Run Scheduling Analysis', + }, + { + category: 'Scheduling', + execute: async () => { + window.open(`${base}/scheduling/goals/new`, '_blank'); + }, + getDisabledReason: ({ user }) => + featurePermissions.schedulingGoals.canCreate(user) + ? null + : 'You do not have permission to create scheduling goals', + id: 'scheduling.newGoal', + isAvailable: () => true, + keywords: ['create', 'add', 'goal'], + label: 'New Scheduling Goal', + }, + { + category: 'Scheduling', + execute: async () => { + window.open(`${base}/scheduling/conditions/new`, '_blank'); + }, + getDisabledReason: ({ user }) => + featurePermissions.schedulingConditions.canCreate(user) + ? null + : 'You do not have permission to create scheduling conditions', + id: 'scheduling.newCondition', + isAvailable: () => true, + keywords: ['create', 'add', 'condition'], + label: 'New Scheduling Condition', + }, + + // ============================================ + // CONSTRAINT COMMANDS + // ============================================ + { + category: 'Constraint', + execute: async ({ fullPlan, user }) => { + if (fullPlan) { + await effects.checkConstraints(fullPlan, user, false); + } + }, + getDisabledReason: ({ constraintsStatus, model, plan, simulationStatus, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (!featurePermissions.constraintRuns.canCreate(user, plan, model)) { + return 'You do not have permission to check constraints'; + } + if (simulationStatus !== Status.Complete) { + return 'Completed simulation required'; + } + if (constraintsStatus === Status.Complete) { + return 'Constraints already checked'; + } + return null; + }, + id: 'constraint.check', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['validate', 'run', 'verify'], + label: 'Check Constraints', + }, + { + category: 'Constraint', + execute: async () => { + window.open(`${base}/constraints/new`, '_blank'); + }, + getDisabledReason: ({ user }) => + featurePermissions.constraints.canCreate(user) ? null : 'You do not have permission to create constraints', + id: 'constraint.new', + isAvailable: () => true, + keywords: ['create', 'add'], + label: 'New Constraint', + }, + + // ============================================ + // EXPANSION COMMANDS + // ============================================ + { + category: 'Expansion', + execute: async () => { + window.open(`${base}/expansion/rules/new`, '_blank'); + }, + getDisabledReason: ({ user }) => + featurePermissions.expansionRules.canCreate(user) ? null : 'You do not have permission to create expansion rules', + id: 'expansion.newRule', + isAvailable: () => true, + keywords: ['create', 'add'], + label: 'New Expansion Rule', + }, + { + category: 'Expansion', + execute: async () => { + window.open(`${base}/expansion/sets/new`, '_blank'); + }, + getDisabledReason: ({ user }) => + featurePermissions.expansionRules.canCreate(user) ? null : 'You do not have permission to create expansion sets', + id: 'expansion.newSet', + isAvailable: () => true, + keywords: ['create', 'add'], + label: 'New Expansion Set', + }, +]; + +/** + * Get all commands filtered by availability and processed with enabled state. + * The enabled state is derived from getDisabledReason - if null, the command is enabled. + */ +export function getAvailableCommands(context: CommandContext): ProcessedCommand[] { + return commands + .filter(cmd => cmd.isAvailable(context)) + .map(cmd => { + const disabledReason = cmd.getDisabledReason(context); + return { + ...cmd, + disabledReason, + enabled: disabledReason === null, + }; + }); +} + +/** + * Get a command by its ID. + */ +export function getCommandById(id: string): Command | undefined { + return commands.find(cmd => cmd.id === id); +} + +/** + * Filter commands by search query (matches label and keywords). + */ +export function filterCommands(commands: ProcessedCommand[], query: string): ProcessedCommand[] { + if (!query.trim()) { + return commands; + } + + const lowerQuery = query.toLowerCase(); + return commands.filter(cmd => { + const labelMatch = cmd.label.toLowerCase().includes(lowerQuery); + const keywordMatch = cmd.keywords?.some(kw => kw.toLowerCase().includes(lowerQuery)) ?? false; + const categoryMatch = cmd.category.toLowerCase().includes(lowerQuery); + return labelMatch || keywordMatch || categoryMatch; + }); +} + +/** + * Group commands by category. + */ +export function groupCommandsByCategory(commands: ProcessedCommand[]): Map { + const groups = new Map(); + + for (const cmd of commands) { + const existing = groups.get(cmd.category) ?? []; + existing.push(cmd); + groups.set(cmd.category, existing); + } + + return groups; +}