Skip to content
Merged
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
63 changes: 1 addition & 62 deletions apps/sim/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
--panel-width: 244px;
--toolbar-triggers-height: 300px;
--editor-connections-height: 200px;
--terminal-height: 100px;
--terminal-height: 145px;
}

.sidebar-container {
Expand Down Expand Up @@ -260,11 +260,6 @@
/**
* Dark mode specific overrides
*/
.dark .error-badge {
background-color: hsl(0, 70%, 20%) !important;
color: hsl(0, 0%, 100%) !important;
}

.dark .bg-red-500 {
@apply bg-red-700;
}
Expand All @@ -285,23 +280,11 @@ input[type="search"]::-ms-clear {
display: none;
}

/**
* Layout utilities
*/
.main-content-overlay {
z-index: 40;
}

/**
* Utilities and special effects
* Animation keyframes are defined in tailwind.config.ts
*/
@layer utilities {
.animation-container {
contain: paint layout style;
will-change: opacity, transform;
}

.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
Expand Down Expand Up @@ -348,46 +331,6 @@ input[type="search"]::-ms-clear {
background-color: hsl(var(--input-background));
}

.bg-brand-primary {
background-color: var(--brand-primary-hex);
}

.bg-brand-primary-hover {
background-color: var(--brand-primary-hover-hex);
}

.hover\:bg-brand-primary-hover:hover {
background-color: var(--brand-primary-hover-hex);
}

.hover\:text-brand-accent-hover:hover {
color: var(--brand-accent-hover-hex);
}

.bg-brand-gradient {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--brand-primary-hex) 85%, white),
var(--brand-primary-hex)
);
}

.border-brand-gradient {
border-color: var(--brand-primary-hex);
}

.shadow-brand-gradient {
box-shadow: inset 0 2px 4px 0 color-mix(in srgb, var(--brand-primary-hex) 60%, transparent);
}

.hover\:bg-brand-gradient-hover:hover {
background: linear-gradient(
to bottom,
var(--brand-primary-hover-hex),
color-mix(in srgb, var(--brand-primary-hex) 90%, black)
);
}

.auth-card {
background-color: rgba(255, 255, 255, 0.9) !important;
border-color: #e5e5e5 !important;
Expand Down Expand Up @@ -419,10 +362,6 @@ input[type="search"]::-ms-clear {
color: #737373 !important;
}

.bg-surface-elevated {
background-color: var(--surface-elevated);
}

.transition-ring {
transition-property: box-shadow, transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
'use client'

import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import { useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('GlobalCommands')

/**
* Detects if the current platform is macOS.
*
* @returns True if running on macOS, false otherwise
*/
function isMacPlatform(): boolean {
if (typeof window === 'undefined') return false
return (
/Mac|iPhone|iPod|iPad/i.test(navigator.platform) ||
/Mac|iPhone|iPod|iPad/i.test(navigator.userAgent)
)
}

/**
* Represents a parsed keyboard shortcut.
*
* We support the following modifiers:
* - Mod: maps to Meta on macOS, Ctrl on other platforms
* - Ctrl, Meta, Shift, Alt
*
* Examples:
* - "Mod+A"
* - "Mod+Shift+T"
* - "Meta+K"
*/
export interface ParsedShortcut {
key: string
mod?: boolean
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}

/**
* Declarative command registration.
*/
export interface GlobalCommand {
/** Unique id for the command. If omitted, one is generated. */
id?: string
/** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
shortcut: string
/**
* Whether to allow the command to run inside editable elements like inputs,
* textareas or contenteditable. Defaults to true to ensure browser defaults
* are overridden when desired.
*/
allowInEditable?: boolean
/**
* Handler invoked when the shortcut is matched. Use this to trigger actions
* like navigation or dispatching application events.
*/
handler: (event: KeyboardEvent) => void
}

interface RegistryCommand extends GlobalCommand {
id: string
parsed: ParsedShortcut
}

interface GlobalCommandsContextValue {
register: (commands: GlobalCommand[]) => () => void
}

const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)

/**
* Parses a human-readable shortcut into a structured representation.
*/
function parseShortcut(shortcut: string): ParsedShortcut {
const parts = shortcut.split('+').map((p) => p.trim())
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
const last = parts[parts.length - 1]

return {
key: last.length === 1 ? last.toLowerCase() : last, // keep non-letter keys verbatim
mod: modifiers.has('mod'),
ctrl: modifiers.has('ctrl'),
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
shift: modifiers.has('shift'),
alt: modifiers.has('alt') || modifiers.has('option'),
}
}

/**
* Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
* interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
*/
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
const isMac = isMacPlatform()
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)

// Normalize key for comparison: for letters compare lowercase
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key

return (
eventKey === parsed.key &&
!!e.ctrlKey === !!expectedCtrl &&
!!e.metaKey === !!expectedMeta &&
!!e.shiftKey === !!parsed.shift &&
!!e.altKey === !!parsed.alt
)
}

/**
* Provider that captures global keyboard shortcuts and routes them to
* registered commands. Commands can be registered from any descendant component.
*/
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const isMac = useMemo(() => isMacPlatform(), [])
const router = useRouter()

const register = useCallback((commands: GlobalCommand[]) => {
const createdIds: string[] = []
for (const cmd of commands) {
const id = cmd.id ?? crypto.randomUUID()
const parsed = parseShortcut(cmd.shortcut)
registryRef.current.set(id, {
...cmd,
id,
parsed,
allowInEditable: cmd.allowInEditable ?? true,
})
createdIds.push(id)
logger.info('Registered global command', { id, shortcut: cmd.shortcut })
}

return () => {
for (const id of createdIds) {
registryRef.current.delete(id)
logger.info('Unregistered global command', { id })
}
}
}, [])

useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return

// Evaluate matches in registration order (latest registration wins naturally
// due to replacement on same id). Break on first match.
for (const [, cmd] of registryRef.current) {
if (!cmd.allowInEditable) {
const ae = document.activeElement
const isEditable =
ae instanceof HTMLInputElement ||
ae instanceof HTMLTextAreaElement ||
ae?.hasAttribute('contenteditable')
if (isEditable) continue
}

if (matchesShortcut(e, cmd.parsed)) {
// Always override default browser behavior for matched commands.
e.preventDefault()
e.stopPropagation()
logger.info('Executing global command', {
id: cmd.id,
shortcut: cmd.shortcut,
key: e.key,
isMac,
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
})
try {
cmd.handler(e)
} catch (err) {
logger.error('Global command handler threw', { id: cmd.id, err })
}
return
}
}
}

window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [isMac, router])

const value = useMemo<GlobalCommandsContextValue>(() => ({ register }), [register])

return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
}

/**
* Registers a set of global commands for the lifetime of the component.
*
* Returns nothing; cleanup is automatic on unmount.
*/
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
const ctx = useContext(GlobalCommandsContext)
if (!ctx) {
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
}

useEffect(() => {
const list = typeof commands === 'function' ? commands() : commands
const unregister = ctx.register(list)
return unregister
// We intentionally want to register once for the given commands
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}
9 changes: 6 additions & 3 deletions apps/sim/app/workspace/[workspaceId]/providers/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from 'react'
import { Tooltip } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SettingsLoader } from './settings-loader'

Expand All @@ -13,9 +14,11 @@ const Providers = React.memo<ProvidersProps>(({ children }) => {
return (
<>
<SettingsLoader />
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
</Tooltip.Provider>
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
</Tooltip.Provider>
</GlobalCommandsProvider>
</>
)
})
Expand Down
Loading