Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/components/app/CommandPalette.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<svelte:options immutable={true} />

<script lang="ts">
import { page } from '$app/stores';
import { Command } from '@nasa-jpl/stellar-svelte';
import { Status } from '../../enums/status';
import {
closeCommandPalette,
commandPaletteContext,
commandPaletteOpen,
toggleCommandPalette,
} from '../../stores/commandPalette';
import { constraintsStatus } from '../../stores/constraints';
import { plan, planReadOnly } from '../../stores/plan';
import { enableScheduling } from '../../stores/scheduling';
import { enableSimulation, simulationStatus } from '../../stores/simulation';
import type { User } from '../../types/app';
import type { CommandContext, ProcessedCommand } from '../../types/command-palette';
import type { Model } from '../../types/model';
import type { Workspace } from '../../types/workspace';
import { filterCommands, getAvailableCommands, groupCommandsByCategory } from '../../utilities/commandRegistry';

/** Current authenticated user */
export let user: User | null = null;

/** Current model (optional, for model-specific pages) */
export let model: Model | null = null;

/** Current workspace (optional, for workspace pages) */
export let workspace: Workspace | null = null;

let searchValue = '';

// Build context reactively from props and stores
// All store values that affect command enabled state must be included here
$: context = buildContext(
user,
$plan,
model,
workspace,
$page.url.pathname,
$planReadOnly,
$simulationStatus,
$constraintsStatus,
$enableSimulation,
$enableScheduling,
);

// Update the global context store whenever local context changes
$: $commandPaletteContext = context;

// Get and filter commands based on context and search
$: allCommands = getAvailableCommands(context);
$: filteredCommands = filterCommands(allCommands, searchValue);
$: groupedCommands = groupCommandsByCategory(filteredCommands);

function buildContext(
user: User | null,
currentPlan: typeof $plan,
model: Model | null,
workspace: Workspace | null,
route: string,
planReadOnlyValue: boolean,
simulationStatusValue: Status | null,
constraintsStatusValue: Status | null,
enableSimulationValue: boolean,
enableSchedulingValue: boolean,
): CommandContext {
// Derive model from plan if not provided
const derivedModel = model ?? (currentPlan ? { id: currentPlan.model.id, owner: currentPlan.model.owner } : null);

return {
constraintsStatus: constraintsStatusValue,
enableScheduling: enableSchedulingValue,
enableSimulation: enableSimulationValue,
fullPlan: currentPlan,
model: derivedModel,
plan: currentPlan
? {
collaborators: currentPlan.collaborators,
id: currentPlan.id,
model_id: currentPlan.model.id,
owner: currentPlan.owner,
}
: null,
planReadOnly: planReadOnlyValue,
route,
simulationStatus: simulationStatusValue,
user,
workspace,
};
}

function handleKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + K to open command palette
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
toggleCommandPalette();
}

// Ctrl/Cmd + Shift + P as alternative (VS Code style)
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'P') {
event.preventDefault();
toggleCommandPalette();
}
}

function handleSelect(command: ProcessedCommand) {
if (!command.enabled) {
return;
}

closeCommandPalette();
try {
// Allow palette to close before potentially opening modals that are
// listening for enter key events
requestAnimationFrame(() => {
command.execute(context);
});
} catch (error) {
console.error(`Command "${command.id}" failed:`, error);
}
}

function handleOpenChange(open: boolean) {
if (!open) {
closeCommandPalette();
searchValue = '';
}
}
</script>

<svelte:document on:keydown={handleKeydown} />

<Command.Dialog open={$commandPaletteOpen} onOpenChange={handleOpenChange} shouldFilter={false}>
<Command.Input placeholder="Type a command or search..." bind:value={searchValue} />
<Command.List>
<Command.Empty>No commands found.</Command.Empty>
{#each [...groupedCommands] as [category, commands]}
<Command.Group heading={category}>
{#each commands as command}
<Command.Item value={command.label} onSelect={() => handleSelect(command)} disabled={!command.enabled}>
<span>{command.label}</span>
{#if command.disabledReason}
<div class="ml-auto text-xs text-muted-foreground">{command.disabledReason}</div>
{:else if command.shortcut}
<Command.Shortcut>
{command.shortcut()}
</Command.Shortcut>
{/if}
</Command.Item>
{/each}
</Command.Group>
{/each}
</Command.List>
</Command.Dialog>

<style>
/* Target the dialog content that contains the command palette */
:global([data-dialog-content]:has([data-cmdk-root])) {
max-width: 680px;
/* Position from top instead of center to prevent jump on close */
top: 20%;
transform: translateX(-50%);
}
</style>
7 changes: 7 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +30,7 @@
beforeNavigate(() => {
// Clear logs on page change
clearLogs();
closeCommandPalette();
});

async function loadPlugins() {
Expand Down Expand Up @@ -61,6 +67,7 @@
</div>
{/if}

<CommandPalette user={data.user} />
<div id="svelte-modal" />

<!-- Disable theme switching for now to prevent user OS/browser dark mode from changing the app which does not yet fully support dark mode -->
Expand Down
40 changes: 40 additions & 0 deletions src/stores/commandPalette.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);

/**
* Store for the current command context
* This is updated by the CommandPaletteProvider component
*/
export const commandPaletteContext = writable<CommandContext>({
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);
}
2 changes: 1 addition & 1 deletion src/stores/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const planRevision = gqlSubscribable<number>(
{ planId },
-1,
null,
({ revision }: Pick<Plan, 'revision'>) => revision,
plan => plan?.revision ?? -1,
);

/* Helper Functions. */
Expand Down
2 changes: 1 addition & 1 deletion src/stores/scheduling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const schedulingColumns: Writable<string> = 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. */

Expand Down
104 changes: 104 additions & 0 deletions src/types/command-palette.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

/**
* 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;
}
Loading
Loading