Skip to content

Commit 1145f5c

Browse files
authored
fix(shortcut): fixed global keyboard commands provider to follow latest ref pattern (#2569)
* fix(shortcut): fixed global commands provider to follow best practices * cleanup * ack PR comment
1 parent 3a50ce4 commit 1145f5c

File tree

3 files changed

+23
-74
lines changed

3 files changed

+23
-74
lines changed

apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx

Lines changed: 15 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@ import { createLogger } from '@/lib/logs/console/logger'
1414

1515
const logger = createLogger('GlobalCommands')
1616

17-
/**
18-
* Detects if the current platform is macOS.
19-
*
20-
* @returns True if running on macOS, false otherwise
21-
*/
2217
function isMacPlatform(): boolean {
2318
if (typeof window === 'undefined') return false
2419
return (
@@ -27,18 +22,6 @@ function isMacPlatform(): boolean {
2722
)
2823
}
2924

30-
/**
31-
* Represents a parsed keyboard shortcut.
32-
*
33-
* We support the following modifiers:
34-
* - Mod: maps to Meta on macOS, Ctrl on other platforms
35-
* - Ctrl, Meta, Shift, Alt
36-
*
37-
* Examples:
38-
* - "Mod+A"
39-
* - "Mod+Shift+T"
40-
* - "Meta+K"
41-
*/
4225
export interface ParsedShortcut {
4326
key: string
4427
mod?: boolean
@@ -48,24 +31,10 @@ export interface ParsedShortcut {
4831
alt?: boolean
4932
}
5033

51-
/**
52-
* Declarative command registration.
53-
*/
5434
export interface GlobalCommand {
55-
/** Unique id for the command. If omitted, one is generated. */
5635
id?: string
57-
/** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
5836
shortcut: string
59-
/**
60-
* Whether to allow the command to run inside editable elements like inputs,
61-
* textareas or contenteditable. Defaults to true to ensure browser defaults
62-
* are overridden when desired.
63-
*/
6437
allowInEditable?: boolean
65-
/**
66-
* Handler invoked when the shortcut is matched. Use this to trigger actions
67-
* like navigation or dispatching application events.
68-
*/
6938
handler: (event: KeyboardEvent) => void
7039
}
7140

@@ -80,16 +49,13 @@ interface GlobalCommandsContextValue {
8049

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

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

9157
return {
92-
key: last.length === 1 ? last.toLowerCase() : last, // keep non-letter keys verbatim
58+
key: last.length === 1 ? last.toLowerCase() : last,
9359
mod: modifiers.has('mod'),
9460
ctrl: modifiers.has('ctrl'),
9561
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
@@ -98,16 +64,10 @@ function parseShortcut(shortcut: string): ParsedShortcut {
9864
}
9965
}
10066

101-
/**
102-
* Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
103-
* interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
104-
*/
10567
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
10668
const isMac = isMacPlatform()
10769
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
10870
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
109-
110-
// Normalize key for comparison: for letters compare lowercase
11171
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
11272

11373
return (
@@ -119,10 +79,6 @@ function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
11979
)
12080
}
12181

122-
/**
123-
* Provider that captures global keyboard shortcuts and routes them to
124-
* registered commands. Commands can be registered from any descendant component.
125-
*/
12682
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
12783
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
12884
const isMac = useMemo(() => isMacPlatform(), [])
@@ -140,13 +96,11 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
14096
allowInEditable: cmd.allowInEditable ?? true,
14197
})
14298
createdIds.push(id)
143-
logger.info('Registered global command', { id, shortcut: cmd.shortcut })
14499
}
145100

146101
return () => {
147102
for (const id of createdIds) {
148103
registryRef.current.delete(id)
149-
logger.info('Unregistered global command', { id })
150104
}
151105
}
152106
}, [])
@@ -155,8 +109,6 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
155109
const onKeyDown = (e: KeyboardEvent) => {
156110
if (e.isComposing) return
157111

158-
// Evaluate matches in registration order (latest registration wins naturally
159-
// due to replacement on same id). Break on first match.
160112
for (const [, cmd] of registryRef.current) {
161113
if (!cmd.allowInEditable) {
162114
const ae = document.activeElement
@@ -168,16 +120,8 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
168120
}
169121

170122
if (matchesShortcut(e, cmd.parsed)) {
171-
// Always override default browser behavior for matched commands.
172123
e.preventDefault()
173124
e.stopPropagation()
174-
logger.info('Executing global command', {
175-
id: cmd.id,
176-
shortcut: cmd.shortcut,
177-
key: e.key,
178-
isMac,
179-
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
180-
})
181125
try {
182126
cmd.handler(e)
183127
} catch (err) {
@@ -197,22 +141,28 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
197141
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
198142
}
199143

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

150+
const commandsRef = useRef<GlobalCommand[]>([])
151+
const list = typeof commands === 'function' ? commands() : commands
152+
commandsRef.current = list
153+
211154
useEffect(() => {
212-
const list = typeof commands === 'function' ? commands() : commands
213-
const unregister = ctx.register(list)
155+
const wrappedCommands = commandsRef.current.map((cmd) => ({
156+
...cmd,
157+
handler: (event: KeyboardEvent) => {
158+
const currentCmd = commandsRef.current.find((c) => c.id === cmd.id)
159+
if (currentCmd) {
160+
currentCmd.handler(event)
161+
}
162+
},
163+
}))
164+
const unregister = ctx.register(wrappedCommands)
214165
return unregister
215-
// We intentionally want to register once for the given commands
216166
// eslint-disable-next-line react-hooks/exhaustive-deps
217167
}, [])
218168
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,7 @@ export function Chat() {
10551055
{isStreaming ? (
10561056
<Button
10571057
onClick={handleStopStreaming}
1058-
className='h-[22px] w-[22px] rounded-full p-0 transition-colors !bg-[var(--c-C0C0C0)] hover:!bg-[var(--c-D0D0D0)]'
1058+
className='!bg-[var(--c-C0C0C0)] hover:!bg-[var(--c-D0D0D0)] h-[22px] w-[22px] rounded-full p-0 transition-colors'
10591059
>
10601060
<Square className='h-2.5 w-2.5 fill-black text-black' />
10611061
</Button>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ export function Panel() {
133133
}
134134
}
135135

136+
/**
137+
* Cancels the currently executing workflow
138+
*/
139+
const cancelWorkflow = useCallback(async () => {
140+
await handleCancelExecution()
141+
}, [handleCancelExecution])
142+
136143
/**
137144
* Runs the workflow with usage limit check
138145
*/
@@ -144,13 +151,6 @@ export function Panel() {
144151
await handleRunWorkflow()
145152
}, [usageExceeded, handleRunWorkflow])
146153

147-
/**
148-
* Cancels the currently executing workflow
149-
*/
150-
const cancelWorkflow = useCallback(async () => {
151-
await handleCancelExecution()
152-
}, [handleCancelExecution])
153-
154154
// Chat state
155155
const { isChatOpen, setIsChatOpen } = useChatStore()
156156
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
@@ -300,7 +300,6 @@ export function Panel() {
300300
{
301301
id: 'run-workflow',
302302
handler: () => {
303-
// Do exactly what the Run button does
304303
if (isExecuting) {
305304
void cancelWorkflow()
306305
} else {

0 commit comments

Comments
 (0)