From afb6abda343631e2764fdfd974c1be235b255511 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Mon, 8 Dec 2025 17:07:33 -0500 Subject: [PATCH 1/5] WIP command palette --- src/components/app/CommandPalette.svelte | 136 +++++++ src/routes/+layout.svelte | 5 + src/stores/commandPalette.ts | 40 ++ src/types/command-palette.ts | 90 +++++ src/utilities/commandRegistry.ts | 475 +++++++++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 src/components/app/CommandPalette.svelte create mode 100644 src/stores/commandPalette.ts create mode 100644 src/types/command-palette.ts create mode 100644 src/utilities/commandRegistry.ts diff --git a/src/components/app/CommandPalette.svelte b/src/components/app/CommandPalette.svelte new file mode 100644 index 0000000000..c12bb651a8 --- /dev/null +++ b/src/components/app/CommandPalette.svelte @@ -0,0 +1,136 @@ + + + + + + + + No commands found. + {#each [...groupedCommands] as [category, commands]} + + {#each commands as command} + handleSelect(command)} + disabled={!command.enabled} + > + {command.label} + {#if command.shortcut} + {command.shortcut} + {/if} + + {/each} + + {/each} + + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bbc137ee56..5555970d71 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,11 +7,15 @@ 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 { 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; @@ -61,6 +65,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/types/command-palette.ts b/src/types/command-palette.ts new file mode 100644 index 0000000000..3efc5e1e76 --- /dev/null +++ b/src/types/command-palette.ts @@ -0,0 +1,90 @@ +import type { User } from './app'; +import type { ModelWithOwner, PlanWithOwners } from './permissions'; +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.) + */ +export interface CommandContext { + /** Current model (if on a model-specific page) */ + model?: ModelWithOwner | null; + /** Current plan (if viewing a plan) */ + plan?: PlanWithOwners | null; + /** Current route pathname */ + route: string; + /** 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; + + /** + * Optional reason why the command is disabled. + * Shown as a tooltip when the command is visible but disabled. + */ + 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; + + /** + * Whether the user has permission to execute this command. + * Returns true if the command can be executed. + */ + isEnabled: (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..feea0001ba --- /dev/null +++ b/src/utilities/commandRegistry.ts @@ -0,0 +1,475 @@ +/** + * 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 { get } from 'svelte/store'; +import { Status } from '../enums/status'; +import { constraintsStatus } from '../stores/constraints'; +import { planReadOnly, plan as planStore } from '../stores/plan'; +import { simulationStatus } from '../stores/simulation'; +import type { Command, CommandContext, ProcessedCommand } from '../types/command-palette'; +import type { Plan } from '../types/plan'; +import effects from './effects'; +import { featurePermissions } from './permissions'; + +// Route patterns for context detection +const PLAN_ROUTES = /\/plans\/\d+/; + +/** + * Helper to check if the current plan is read-only + */ +function isPlanReadOnly(): boolean { + return get(planReadOnly); +} + +/** + * Helper to check if route matches a pattern + */ +function matchesRoute(route: string, pattern: RegExp): boolean { + return pattern.test(route); +} + +/** + * Get the full plan from the store (needed for effects that require Plan type) + */ +function getFullPlan(): Plan | null { + return get(planStore); +} + +/** + * 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`); + }, + id: 'nav.plans', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'list', 'open'], + label: 'Go to Plans', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/models`); + }, + id: 'nav.models', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'mission', 'open'], + label: 'Go to Models', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/constraints`); + }, + id: 'nav.constraints', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'open'], + label: 'Go to Constraints', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/scheduling`); + }, + id: 'nav.scheduling', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'goals', 'conditions', 'open'], + label: 'Go to Scheduling', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/expansion/rules`); + }, + id: 'nav.expansion', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'rules', 'sets', 'open'], + label: 'Go to Expansion', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/workspaces`); + }, + id: 'nav.workspaces', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'open'], + label: 'Go to Workspaces', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/external-sources/sources`); + }, + id: 'nav.externalSources', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'events', 'open'], + label: 'Go to External Sources', + }, + { + category: 'Navigation', + execute: async () => { + await goto(`${base}/dictionaries`); + }, + id: 'nav.dictionaries', + isAvailable: () => true, + isEnabled: () => true, + keywords: ['navigate', 'command', 'channel', 'open'], + label: 'Go to Dictionaries', + }, + + // ============================================ + // PLAN COMMANDS + // ============================================ + { + category: 'Plan', + execute: async ({ user }) => { + const fullPlan = getFullPlan(); + if (fullPlan) { + await effects.createPlanBranch(fullPlan, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + return featurePermissions.planBranch.canCreateBranch(user, plan, model) + ? null + : 'You do not have permission to duplicate this plan'; + }, + id: 'plan.duplicate', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + isEnabled: ({ model, plan, user }) => + plan != null && model != null && featurePermissions.planBranch.canCreateBranch(user, plan, model), + keywords: ['copy', 'branch', 'clone'], + label: 'Duplicate Current Plan', + }, + { + category: 'Plan', + execute: async ({ user }) => { + const fullPlan = getFullPlan(); + if (fullPlan) { + await effects.createPlanSnapshot(fullPlan, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + return featurePermissions.planSnapshot.canCreate(user, plan, model) + ? null + : 'You do not have permission to create snapshots'; + }, + id: 'plan.createSnapshot', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + isEnabled: ({ model, plan, user }) => + plan != null && model != null && featurePermissions.planSnapshot.canCreate(user, plan, model), + keywords: ['save', 'backup', 'version'], + label: 'Create Plan Snapshot', + }, + + // ============================================ + // SIMULATION COMMANDS + // ============================================ + { + category: 'Simulation', + execute: async ({ user }) => { + const fullPlan = getFullPlan(); + if (fullPlan) { + await effects.simulate(fullPlan, false, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (isPlanReadOnly()) { + return 'Plan is read-only'; + } + return featurePermissions.simulation.canRun(user, plan, model) + ? null + : 'You do not have permission to run simulations'; + }, + id: 'simulation.run', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + isEnabled: ({ model, plan, user }) => { + if (!plan || !model) { + return false; + } + if (isPlanReadOnly()) { + return false; + } + return featurePermissions.simulation.canRun(user, plan, model); + }, + keywords: ['simulate', 'execute', 'start'], + label: 'Run Simulation', + shortcut: 'Ctrl+Shift+S', + }, + + // ============================================ + // SCHEDULING COMMANDS + // ============================================ + { + category: 'Scheduling', + execute: async ({ user }) => { + const fullPlan = getFullPlan(); + if (fullPlan) { + await effects.schedule(false, fullPlan, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; + } + if (isPlanReadOnly()) { + return 'Plan is read-only'; + } + return featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model) + ? null + : 'You do not have permission to run scheduling'; + }, + id: 'scheduling.run', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + isEnabled: ({ model, plan, user }) => { + if (!plan || !model) { + return false; + } + if (isPlanReadOnly()) { + return false; + } + return featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model); + }, + keywords: ['schedule', 'goals', 'execute'], + label: 'Run Scheduling', + }, + { + category: 'Scheduling', + execute: async () => { + await goto(`${base}/scheduling/goals/new`); + }, + getDisabledReason: ({ user }) => + featurePermissions.schedulingGoals.canCreate(user) + ? null + : 'You do not have permission to create scheduling goals', + id: 'scheduling.newGoal', + isAvailable: () => true, + isEnabled: ({ user }) => featurePermissions.schedulingGoals.canCreate(user), + keywords: ['create', 'add', 'goal'], + label: 'New Scheduling Goal', + }, + { + category: 'Scheduling', + execute: async () => { + await goto(`${base}/scheduling/conditions/new`); + }, + getDisabledReason: ({ user }) => + featurePermissions.schedulingConditions.canCreate(user) + ? null + : 'You do not have permission to create scheduling conditions', + id: 'scheduling.newCondition', + isAvailable: () => true, + isEnabled: ({ user }) => featurePermissions.schedulingConditions.canCreate(user), + keywords: ['create', 'add', 'condition'], + label: 'New Scheduling Condition', + }, + + // ============================================ + // CONSTRAINT COMMANDS + // ============================================ + { + category: 'Constraint', + execute: async ({ user }) => { + const fullPlan = getFullPlan(); + if (fullPlan) { + await effects.checkConstraints(fullPlan, user, false); + } + }, + getDisabledReason: ({ model, plan, 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'; + } + + const simStatus = get(simulationStatus); + const constStatus = get(constraintsStatus); + + if (simStatus !== Status.Complete) { + return 'Completed simulation required'; + } + if (constStatus === Status.Complete) { + return 'Constraints already checked'; + } + + return null; + }, + id: 'constraint.check', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + isEnabled: ({ model, plan, user }) => { + if (!plan || !model) { + return false; + } + if (!featurePermissions.constraintRuns.canCreate(user, plan, model)) { + return false; + } + + const simStatus = get(simulationStatus); + const constStatus = get(constraintsStatus); + + // Disable if simulation is not complete + if (simStatus !== Status.Complete) { + return false; + } + + // Disable if constraints are already complete (no need to re-run) + if (constStatus === Status.Complete) { + return false; + } + + return true; + }, + keywords: ['validate', 'run', 'verify'], + label: 'Check Constraints', + }, + { + category: 'Constraint', + execute: async () => { + await goto(`${base}/constraints/new`); + }, + getDisabledReason: ({ user }) => + featurePermissions.constraints.canCreate(user) ? null : 'You do not have permission to create constraints', + id: 'constraint.new', + isAvailable: () => true, + isEnabled: ({ user }) => featurePermissions.constraints.canCreate(user), + keywords: ['create', 'add'], + label: 'New Constraint', + }, + + // ============================================ + // EXPANSION COMMANDS + // ============================================ + { + category: 'Expansion', + execute: async () => { + await goto(`${base}/expansion/rules/new`); + }, + getDisabledReason: ({ user }) => + featurePermissions.expansionRules.canCreate(user) ? null : 'You do not have permission to create expansion rules', + id: 'expansion.newRule', + isAvailable: () => true, + isEnabled: ({ user }) => featurePermissions.expansionRules.canCreate(user), + keywords: ['create', 'add'], + label: 'New Expansion Rule', + }, + { + category: 'Expansion', + execute: async () => { + await goto(`${base}/expansion/sets/new`); + }, + getDisabledReason: ({ user }) => + featurePermissions.expansionRules.canCreate(user) ? null : 'You do not have permission to create expansion sets', + id: 'expansion.newSet', + isAvailable: () => true, + isEnabled: ({ user }) => featurePermissions.expansionRules.canCreate(user), + keywords: ['create', 'add'], + label: 'New Expansion Set', + }, +]; + +/** + * Get all commands filtered by availability and processed with enabled state. + */ +export function getAvailableCommands(context: CommandContext): ProcessedCommand[] { + return commands + .filter(cmd => cmd.isAvailable(context)) + .map(cmd => ({ + ...cmd, + disabledReason: cmd.getDisabledReason?.(context) ?? null, + enabled: cmd.isEnabled(context), + })); +} + +/** + * 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; +} From 0388a544b35b6fbef533946bcacfb9c868de0140 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Tue, 9 Dec 2025 08:54:03 -0800 Subject: [PATCH 2/5] Close command palette before navigate --- src/routes/+layout.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5555970d71..3b85bd4645 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -10,6 +10,7 @@ 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'; @@ -29,6 +30,7 @@ beforeNavigate(() => { // Clear logs on page change clearLogs(); + closeCommandPalette(); }); async function loadPlugins() { From a03b5086f4f6ddd88e1782c837d6b450eabe52bf Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Tue, 9 Dec 2025 08:54:15 -0800 Subject: [PATCH 3/5] Bug fixes and refactoring --- src/components/app/CommandPalette.svelte | 51 +++---- src/types/command-palette.ts | 16 +-- src/utilities/commandRegistry.ts | 161 +++++++++++------------ 3 files changed, 112 insertions(+), 116 deletions(-) diff --git a/src/components/app/CommandPalette.svelte b/src/components/app/CommandPalette.svelte index c12bb651a8..4438b6b3fa 100644 --- a/src/components/app/CommandPalette.svelte +++ b/src/components/app/CommandPalette.svelte @@ -3,7 +3,6 @@ - + + + No commands found. {#each [...groupedCommands] as [category, commands]} {#each commands as command} - handleSelect(command)} - disabled={!command.enabled} - > + handleSelect(command)} disabled={!command.enabled}> {command.label} - {#if command.shortcut} - {command.shortcut} + {#if command.disabledReason} +
{command.disabledReason}
+ {:else if command.shortcut} + + {command.shortcut()} + {/if}
{/each} @@ -134,3 +127,13 @@ {/each}
+ + diff --git a/src/types/command-palette.ts b/src/types/command-palette.ts index 3efc5e1e76..4394ffc823 100644 --- a/src/types/command-palette.ts +++ b/src/types/command-palette.ts @@ -49,10 +49,12 @@ export interface Command { execute: (context: CommandContext) => Promise; /** - * Optional reason why the command is disabled. - * Shown as a tooltip when the command is visible but disabled. + * 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; + getDisabledReason: (context: CommandContext) => string | null; /** Unique identifier for the command */ id: string; @@ -64,12 +66,6 @@ export interface Command { */ isAvailable: (context: CommandContext) => boolean; - /** - * Whether the user has permission to execute this command. - * Returns true if the command can be executed. - */ - isEnabled: (context: CommandContext) => boolean; - /** Optional keywords for fuzzy search (beyond the label) */ keywords?: string[]; @@ -77,7 +73,7 @@ export interface Command { label: string; /** Optional keyboard shortcut hint to display */ - shortcut?: string; + shortcut?: () => string; } /** diff --git a/src/utilities/commandRegistry.ts b/src/utilities/commandRegistry.ts index feea0001ba..005b53f96c 100644 --- a/src/utilities/commandRegistry.ts +++ b/src/utilities/commandRegistry.ts @@ -21,10 +21,12 @@ import { get } from 'svelte/store'; import { Status } from '../enums/status'; import { constraintsStatus } from '../stores/constraints'; import { planReadOnly, plan as planStore } from '../stores/plan'; -import { simulationStatus } from '../stores/simulation'; +import { enableScheduling } from '../stores/scheduling'; +import { enableSimulation, simulationStatus } from '../stores/simulation'; import type { Command, CommandContext, ProcessedCommand } from '../types/command-palette'; import type { Plan } from '../types/plan'; import effects from './effects'; +import { isMacOs } from './generic'; import { featurePermissions } from './permissions'; // Route patterns for context detection @@ -64,9 +66,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/plans`); }, + getDisabledReason: () => null, id: 'nav.plans', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'list', 'open'], label: 'Go to Plans', }, @@ -75,9 +77,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/models`); }, + getDisabledReason: () => null, id: 'nav.models', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'mission', 'open'], label: 'Go to Models', }, @@ -86,9 +88,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/constraints`); }, + getDisabledReason: () => null, id: 'nav.constraints', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'open'], label: 'Go to Constraints', }, @@ -97,9 +99,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/scheduling`); }, + getDisabledReason: () => null, id: 'nav.scheduling', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'goals', 'conditions', 'open'], label: 'Go to Scheduling', }, @@ -108,9 +110,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/expansion/rules`); }, + getDisabledReason: () => null, id: 'nav.expansion', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'rules', 'sets', 'open'], label: 'Go to Expansion', }, @@ -119,9 +121,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/workspaces`); }, + getDisabledReason: () => null, id: 'nav.workspaces', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'open'], label: 'Go to Workspaces', }, @@ -130,9 +132,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/external-sources/sources`); }, + getDisabledReason: () => null, id: 'nav.externalSources', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'events', 'open'], label: 'Go to External Sources', }, @@ -141,9 +143,9 @@ export const commands: Command[] = [ execute: async () => { await goto(`${base}/dictionaries`); }, + getDisabledReason: () => null, id: 'nav.dictionaries', isAvailable: () => true, - isEnabled: () => true, keywords: ['navigate', 'command', 'channel', 'open'], label: 'Go to Dictionaries', }, @@ -166,16 +168,15 @@ export const commands: Command[] = [ if (!model) { return 'No model available'; } - return featurePermissions.planBranch.canCreateBranch(user, plan, model) - ? null - : 'You do not have permission to duplicate this plan'; + if (!featurePermissions.planBranch.canCreateBranch(user, plan, model)) { + return 'You do not have permission to create a plan branch'; + } + return null; }, - id: 'plan.duplicate', + id: 'plan.createBranch', isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), - isEnabled: ({ model, plan, user }) => - plan != null && model != null && featurePermissions.planBranch.canCreateBranch(user, plan, model), keywords: ['copy', 'branch', 'clone'], - label: 'Duplicate Current Plan', + label: 'Create Plan Branch', }, { category: 'Plan', @@ -192,14 +193,13 @@ export const commands: Command[] = [ if (!model) { return 'No model available'; } - return featurePermissions.planSnapshot.canCreate(user, plan, model) - ? null - : 'You do not have permission to create snapshots'; + 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), - isEnabled: ({ model, plan, user }) => - plan != null && model != null && featurePermissions.planSnapshot.canCreate(user, plan, model), keywords: ['save', 'backup', 'version'], label: 'Create Plan Snapshot', }, @@ -225,24 +225,19 @@ export const commands: Command[] = [ if (isPlanReadOnly()) { return 'Plan is read-only'; } - return featurePermissions.simulation.canRun(user, plan, model) - ? null - : 'You do not have permission to run simulations'; - }, - id: 'simulation.run', - isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), - isEnabled: ({ model, plan, user }) => { - if (!plan || !model) { - return false; + if (!featurePermissions.simulation.canRun(user, plan, model)) { + return 'You do not have permission to run simulations'; } - if (isPlanReadOnly()) { - return false; + if (!get(enableSimulation)) { + return 'Simulation up-to-date'; } - return featurePermissions.simulation.canRun(user, plan, model); + return null; }, + id: 'simulation.run', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), keywords: ['simulate', 'execute', 'start'], label: 'Run Simulation', - shortcut: 'Ctrl+Shift+S', + shortcut: () => `${isMacOs() ? '⌘' : 'CTRL'}S`, }, // ============================================ @@ -266,28 +261,54 @@ export const commands: Command[] = [ if (isPlanReadOnly()) { return 'Plan is read-only'; } - return featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model) - ? null - : 'You do not have permission to run scheduling'; + if (!featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model)) { + return 'You do not have permission to run scheduling'; + } + if (!get(enableScheduling)) { + return 'No scheduling goals enabled'; + } + return null; }, id: 'scheduling.run', isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), - isEnabled: ({ model, plan, user }) => { - if (!plan || !model) { - return false; + keywords: ['schedule', 'goals', 'execute'], + label: 'Run Scheduling', + }, + { + category: 'Scheduling', + execute: async ({ user }) => { + const fullPlan = getFullPlan(); + if (fullPlan) { + await effects.schedule(true, fullPlan, user); + } + }, + getDisabledReason: ({ model, plan, user }) => { + if (!plan) { + return 'No plan selected'; + } + if (!model) { + return 'No model available'; } if (isPlanReadOnly()) { - return false; + return 'Plan is read-only'; } - return featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model); + if (!featurePermissions.schedulingGoalsPlanSpec.canRun(user, plan, model)) { + return 'You do not have permission to run scheduling'; + } + if (!get(enableScheduling)) { + return 'No scheduling goals enabled'; + } + return null; }, - keywords: ['schedule', 'goals', 'execute'], - label: 'Run Scheduling', + id: 'scheduling.analyze', + isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), + keywords: ['schedule', 'goals', 'analyze'], + label: 'Run Scheduling Analysis', }, { category: 'Scheduling', execute: async () => { - await goto(`${base}/scheduling/goals/new`); + window.open(`${base}/scheduling/goals/new`, '_blank'); }, getDisabledReason: ({ user }) => featurePermissions.schedulingGoals.canCreate(user) @@ -295,14 +316,13 @@ export const commands: Command[] = [ : 'You do not have permission to create scheduling goals', id: 'scheduling.newGoal', isAvailable: () => true, - isEnabled: ({ user }) => featurePermissions.schedulingGoals.canCreate(user), keywords: ['create', 'add', 'goal'], label: 'New Scheduling Goal', }, { category: 'Scheduling', execute: async () => { - await goto(`${base}/scheduling/conditions/new`); + window.open(`${base}/scheduling/conditions/new`, '_blank'); }, getDisabledReason: ({ user }) => featurePermissions.schedulingConditions.canCreate(user) @@ -310,7 +330,6 @@ export const commands: Command[] = [ : 'You do not have permission to create scheduling conditions', id: 'scheduling.newCondition', isAvailable: () => true, - isEnabled: ({ user }) => featurePermissions.schedulingConditions.canCreate(user), keywords: ['create', 'add', 'condition'], label: 'New Scheduling Condition', }, @@ -351,42 +370,18 @@ export const commands: Command[] = [ }, id: 'constraint.check', isAvailable: ({ route }) => matchesRoute(route, PLAN_ROUTES), - isEnabled: ({ model, plan, user }) => { - if (!plan || !model) { - return false; - } - if (!featurePermissions.constraintRuns.canCreate(user, plan, model)) { - return false; - } - - const simStatus = get(simulationStatus); - const constStatus = get(constraintsStatus); - - // Disable if simulation is not complete - if (simStatus !== Status.Complete) { - return false; - } - - // Disable if constraints are already complete (no need to re-run) - if (constStatus === Status.Complete) { - return false; - } - - return true; - }, keywords: ['validate', 'run', 'verify'], label: 'Check Constraints', }, { category: 'Constraint', execute: async () => { - await goto(`${base}/constraints/new`); + 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, - isEnabled: ({ user }) => featurePermissions.constraints.canCreate(user), keywords: ['create', 'add'], label: 'New Constraint', }, @@ -397,26 +392,24 @@ export const commands: Command[] = [ { category: 'Expansion', execute: async () => { - await goto(`${base}/expansion/rules/new`); + 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, - isEnabled: ({ user }) => featurePermissions.expansionRules.canCreate(user), keywords: ['create', 'add'], label: 'New Expansion Rule', }, { category: 'Expansion', execute: async () => { - await goto(`${base}/expansion/sets/new`); + 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, - isEnabled: ({ user }) => featurePermissions.expansionRules.canCreate(user), keywords: ['create', 'add'], label: 'New Expansion Set', }, @@ -424,15 +417,19 @@ export const commands: Command[] = [ /** * 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 => ({ - ...cmd, - disabledReason: cmd.getDisabledReason?.(context) ?? null, - enabled: cmd.isEnabled(context), - })); + .map(cmd => { + const disabledReason = cmd.getDisabledReason(context); + return { + ...cmd, + disabledReason, + enabled: disabledReason === null, + }; + }); } /** From f7250d55ef6aeae0dbb1ad3d71d86b26ab996e20 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Tue, 9 Dec 2025 09:31:39 -0800 Subject: [PATCH 4/5] Bug fixing --- src/components/app/CommandPalette.svelte | 30 +++++++++++++++-- src/stores/plan.ts | 2 +- src/stores/scheduling.ts | 2 +- src/types/command-palette.ts | 15 +++++++++ src/utilities/commandRegistry.ts | 41 ++++++++---------------- 5 files changed, 58 insertions(+), 32 deletions(-) diff --git a/src/components/app/CommandPalette.svelte b/src/components/app/CommandPalette.svelte index 4438b6b3fa..247d6852db 100644 --- a/src/components/app/CommandPalette.svelte +++ b/src/components/app/CommandPalette.svelte @@ -3,13 +3,17 @@