@tooee/commands 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/command-store.d.ts +147 -0
- package/dist/command-store.d.ts.map +1 -0
- package/dist/command-store.js +367 -0
- package/dist/command-store.js.map +1 -0
- package/dist/context.d.ts +52 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +244 -153
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/match.d.ts.map +1 -1
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -1
- package/dist/mode.d.ts +7 -1
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +12 -3
- package/dist/mode.js.map +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +17 -5
- package/dist/parse.js.map +1 -1
- package/dist/sequence.d.ts +17 -2
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +66 -45
- package/dist/sequence.js.map +1 -1
- package/dist/types.d.ts +51 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-actions.d.ts.map +1 -1
- package/dist/use-actions.js +6 -3
- package/dist/use-actions.js.map +1 -1
- package/dist/use-command.d.ts.map +1 -1
- package/dist/use-command.js +12 -4
- package/dist/use-command.js.map +1 -1
- package/package.json +5 -1
- package/src/command-store.ts +554 -0
- package/src/context.tsx +353 -165
- package/src/index.ts +27 -1
- package/src/match.ts +1 -0
- package/src/mode.tsx +31 -3
- package/src/parse.ts +20 -5
- package/src/sequence.ts +80 -52
- package/src/types.ts +56 -3
- package/src/use-actions.ts +5 -3
- package/src/use-command.ts +13 -4
package/src/context.tsx
CHANGED
|
@@ -5,29 +5,34 @@ import {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useEffect,
|
|
7
7
|
useMemo,
|
|
8
|
-
useState,
|
|
9
8
|
type ReactNode,
|
|
10
9
|
} from "react"
|
|
11
10
|
import { useKeyboard } from "@opentui/react"
|
|
11
|
+
import { useSelector } from "@xstate/store-react"
|
|
12
12
|
import type {
|
|
13
|
+
ActiveCommandSurface,
|
|
13
14
|
Command,
|
|
14
15
|
CommandContext,
|
|
15
16
|
CommandGroup,
|
|
16
17
|
CommandRegistry,
|
|
17
18
|
CommandSequenceState,
|
|
18
|
-
|
|
19
|
-
ParsedStep,
|
|
19
|
+
CommandSurfaceRole,
|
|
20
20
|
RegisteredCommandGroup,
|
|
21
21
|
} from "./types.js"
|
|
22
22
|
import type { Mode } from "./mode.js"
|
|
23
23
|
import { ModeProvider, useMode, useSetMode } from "./mode.js"
|
|
24
24
|
import { parseHotkey } from "./parse.js"
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
import {
|
|
26
|
+
ROOT_SURFACE_ID,
|
|
27
|
+
createCommandStore,
|
|
28
|
+
selectActiveModalSurface,
|
|
29
|
+
selectSequence,
|
|
30
|
+
selectSurfaceCommandMap,
|
|
31
|
+
stepsKey,
|
|
32
|
+
type CommandStore,
|
|
33
|
+
type ContextGetter,
|
|
34
|
+
type SurfaceRecord,
|
|
35
|
+
} from "./command-store.js"
|
|
31
36
|
|
|
32
37
|
interface CommandContextValue {
|
|
33
38
|
registry: CommandRegistry
|
|
@@ -36,8 +41,37 @@ interface CommandContextValue {
|
|
|
36
41
|
groups: Map<string, RegisteredCommandGroup>
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
/** Internal provider value: the store plus the surface this subtree registers to. */
|
|
45
|
+
interface CommandStoreContextValue {
|
|
46
|
+
commandStore: CommandStore
|
|
47
|
+
/** The surface record `useCommand` registrations under this subtree target. */
|
|
48
|
+
surface: SurfaceRecord
|
|
49
|
+
leaderKey?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CommandContext = createContext<CommandStoreContextValue | null>(null)
|
|
40
53
|
const CommandSequenceContext = createContext<CommandSequenceState | null>(null)
|
|
54
|
+
/** Nesting depth of the nearest command surface (0 at the root app). */
|
|
55
|
+
const CommandSurfaceDepthContext = createContext(0)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fallback store for hooks that must not throw outside a CommandProvider
|
|
59
|
+
* (useActiveCommandSurface, useSurfaceCommands). Never dispatched to.
|
|
60
|
+
*/
|
|
61
|
+
const FALLBACK_COMMAND_STORE = createCommandStore({
|
|
62
|
+
root: {
|
|
63
|
+
getMode: () => "cursor",
|
|
64
|
+
// Placeholder context: augmented domain fields (overlay, view, ...) are
|
|
65
|
+
// only present where their providers run; dispatch never reaches this.
|
|
66
|
+
buildCtx: () =>
|
|
67
|
+
({
|
|
68
|
+
mode: "cursor",
|
|
69
|
+
setMode: () => {},
|
|
70
|
+
commands: { invoke: () => {}, list: () => [] },
|
|
71
|
+
exit: () => {},
|
|
72
|
+
}) as unknown as CommandContext,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
41
75
|
|
|
42
76
|
export interface CommandProviderProps {
|
|
43
77
|
children: ReactNode
|
|
@@ -47,6 +81,11 @@ export interface CommandProviderProps {
|
|
|
47
81
|
sequenceTimeoutMs?: number
|
|
48
82
|
}
|
|
49
83
|
|
|
84
|
+
interface RootAccess {
|
|
85
|
+
getMode: () => Mode
|
|
86
|
+
buildCtx: () => CommandContext
|
|
87
|
+
}
|
|
88
|
+
|
|
50
89
|
export function CommandProvider({
|
|
51
90
|
children,
|
|
52
91
|
leader,
|
|
@@ -54,9 +93,52 @@ export function CommandProvider({
|
|
|
54
93
|
initialMode,
|
|
55
94
|
sequenceTimeoutMs,
|
|
56
95
|
}: CommandProviderProps) {
|
|
96
|
+
// The store is created here (above the root ModeProvider) so mode changes
|
|
97
|
+
// can be routed into it as transitions; the dispatcher below installs the
|
|
98
|
+
// real root accessors before any key can dispatch.
|
|
99
|
+
const rootAccessRef = useRef<RootAccess>({
|
|
100
|
+
getMode: () => initialMode ?? "cursor",
|
|
101
|
+
// Placeholder until CommandDispatcher installs the real accessors on its
|
|
102
|
+
// first render (before any key can dispatch).
|
|
103
|
+
buildCtx: () =>
|
|
104
|
+
({
|
|
105
|
+
mode: initialMode ?? "cursor",
|
|
106
|
+
setMode: () => {},
|
|
107
|
+
commands: { invoke: () => {}, list: () => [] },
|
|
108
|
+
exit: () => {},
|
|
109
|
+
}) as unknown as CommandContext,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const storeRef = useRef<CommandStore | null>(null)
|
|
113
|
+
if (storeRef.current === null) {
|
|
114
|
+
storeRef.current = createCommandStore({
|
|
115
|
+
leader,
|
|
116
|
+
keymap,
|
|
117
|
+
sequenceTimeoutMs,
|
|
118
|
+
root: {
|
|
119
|
+
getMode: () => rootAccessRef.current.getMode(),
|
|
120
|
+
buildCtx: () => rootAccessRef.current.buildCtx(),
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
const commandStore = storeRef.current
|
|
125
|
+
|
|
126
|
+
// Root mode changes are transitions: reset any pending chord synchronously
|
|
127
|
+
// (this replaces the old post-render tracker-reset effect).
|
|
128
|
+
const handleModeChange = useCallback(
|
|
129
|
+
() => commandStore.modeChanged(ROOT_SURFACE_ID),
|
|
130
|
+
[commandStore],
|
|
131
|
+
)
|
|
132
|
+
|
|
57
133
|
return (
|
|
58
|
-
<ModeProvider initialMode={initialMode}>
|
|
59
|
-
<CommandDispatcher
|
|
134
|
+
<ModeProvider initialMode={initialMode} onModeChange={handleModeChange}>
|
|
135
|
+
<CommandDispatcher
|
|
136
|
+
commandStore={commandStore}
|
|
137
|
+
rootAccess={rootAccessRef}
|
|
138
|
+
leader={leader}
|
|
139
|
+
keymap={keymap}
|
|
140
|
+
sequenceTimeoutMs={sequenceTimeoutMs}
|
|
141
|
+
>
|
|
60
142
|
{children}
|
|
61
143
|
</CommandDispatcher>
|
|
62
144
|
</ModeProvider>
|
|
@@ -65,179 +147,203 @@ export function CommandProvider({
|
|
|
65
147
|
|
|
66
148
|
function CommandDispatcher({
|
|
67
149
|
children,
|
|
150
|
+
commandStore,
|
|
151
|
+
rootAccess,
|
|
68
152
|
leader,
|
|
69
153
|
keymap,
|
|
70
154
|
sequenceTimeoutMs,
|
|
71
155
|
}: {
|
|
72
156
|
children: ReactNode
|
|
157
|
+
commandStore: CommandStore
|
|
158
|
+
rootAccess: { current: RootAccess }
|
|
73
159
|
leader?: string
|
|
74
160
|
keymap?: Record<string, string>
|
|
75
161
|
sequenceTimeoutMs?: number
|
|
76
162
|
}) {
|
|
77
|
-
const registryRef = useRef<CommandRegistry | null>(null)
|
|
78
|
-
const contextSourcesRef = useRef(new Map<string, ContextGetter>())
|
|
79
|
-
const groupsRef = useRef(new Map<string, RegisteredCommandGroup>())
|
|
80
163
|
const mode = useMode()
|
|
81
164
|
const modeRef = useRef(mode)
|
|
82
165
|
modeRef.current = mode
|
|
83
166
|
const setMode = useSetMode()
|
|
84
|
-
const [sequenceState, setSequenceState] = useState<CommandSequenceState | null>(null)
|
|
85
|
-
const clearSequenceStateRef = useRef(() => setSequenceState(null))
|
|
86
|
-
clearSequenceStateRef.current = () => setSequenceState(null)
|
|
87
167
|
|
|
88
168
|
const buildCtx = useCallback((): CommandContext => {
|
|
169
|
+
const registry = commandStore.registryFor(commandStore.rootRecord)
|
|
89
170
|
const ctx: Record<string, any> = {
|
|
90
171
|
mode: modeRef.current,
|
|
91
172
|
setMode,
|
|
92
173
|
commands: {
|
|
93
|
-
invoke: (id: string) =>
|
|
94
|
-
list: () => Array.from(
|
|
174
|
+
invoke: (id: string) => registry.invoke(id),
|
|
175
|
+
list: () => Array.from(registry.commands.values()),
|
|
95
176
|
},
|
|
96
177
|
exit: () => {},
|
|
97
178
|
}
|
|
98
|
-
for (const getter of
|
|
179
|
+
for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
|
|
99
180
|
Object.assign(ctx, getter())
|
|
100
181
|
}
|
|
101
182
|
return ctx as CommandContext
|
|
102
|
-
}, [setMode])
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
},
|
|
183
|
+
}, [commandStore, setMode])
|
|
184
|
+
|
|
185
|
+
const buildCtxRef = useRef(buildCtx)
|
|
186
|
+
buildCtxRef.current = buildCtx
|
|
187
|
+
|
|
188
|
+
// Install the live root accessors and config (ref writes, same pattern as
|
|
189
|
+
// the previous modeRef mirrors; leader/keymap are read per dispatch).
|
|
190
|
+
rootAccess.current.getMode = () => modeRef.current
|
|
191
|
+
rootAccess.current.buildCtx = () => buildCtxRef.current()
|
|
192
|
+
commandStore.setConfig({ leader, keymap, sequenceTimeoutMs })
|
|
193
|
+
|
|
194
|
+
useKeyboard((event) => {
|
|
195
|
+
if (event.defaultPrevented) return
|
|
196
|
+
const result = commandStore.key(event)
|
|
197
|
+
if (result.handled) {
|
|
198
|
+
event.preventDefault()
|
|
199
|
+
result.invoke?.()
|
|
121
200
|
}
|
|
122
|
-
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// Clear the store's key buffer and pending timeout on dispatcher unmount so
|
|
204
|
+
// the timer cannot fire after the tree is gone.
|
|
205
|
+
useEffect(() => () => commandStore.dispose(), [commandStore])
|
|
123
206
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
207
|
+
const sequenceState = useSelector(commandStore.store, (s) => selectSequence(s.context))
|
|
208
|
+
|
|
209
|
+
const ctxValue = useMemo<CommandStoreContextValue>(
|
|
210
|
+
() => ({
|
|
211
|
+
commandStore,
|
|
212
|
+
surface: commandStore.rootRecord,
|
|
213
|
+
leaderKey: leader,
|
|
128
214
|
}),
|
|
215
|
+
[commandStore, leader],
|
|
129
216
|
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const cacheKey = `${hotkey}:${leader ?? ""}`
|
|
136
|
-
let parsed = cache.get(cacheKey)
|
|
137
|
-
if (!parsed) {
|
|
138
|
-
parsed = parseHotkey(hotkey, leader)
|
|
139
|
-
cache.set(cacheKey, parsed)
|
|
140
|
-
}
|
|
141
|
-
return parsed
|
|
142
|
-
},
|
|
143
|
-
[leader],
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<CommandContext.Provider value={ctxValue}>
|
|
220
|
+
<CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
|
|
221
|
+
</CommandContext.Provider>
|
|
144
222
|
)
|
|
223
|
+
}
|
|
145
224
|
|
|
146
|
-
|
|
147
|
-
|
|
225
|
+
export interface CommandSurfaceProviderProps {
|
|
226
|
+
children: ReactNode
|
|
227
|
+
/** Stable id for the surface (typically the overlay id). */
|
|
228
|
+
id: string
|
|
229
|
+
/** Interaction role (default "modal"). */
|
|
230
|
+
role?: CommandSurfaceRole
|
|
231
|
+
/** Initial local mode for this surface (default "cursor"). */
|
|
232
|
+
initialMode?: Mode
|
|
233
|
+
}
|
|
148
234
|
|
|
149
|
-
|
|
150
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Mounts an overlay-owned command surface. Children registered via `useCommand`
|
|
237
|
+
* target this surface's local registry, and `useMode`/`useSetMode` read/write
|
|
238
|
+
* this surface's local mode instead of the root app's mode.
|
|
239
|
+
*
|
|
240
|
+
* While a `modal` surface is topmost it owns keyboard input and suspends the
|
|
241
|
+
* parent app's commands; a `passive` surface never becomes the keyboard owner.
|
|
242
|
+
*/
|
|
243
|
+
export function CommandSurfaceProvider({
|
|
244
|
+
children,
|
|
245
|
+
id,
|
|
246
|
+
role = "modal",
|
|
247
|
+
initialMode = "cursor",
|
|
248
|
+
}: CommandSurfaceProviderProps) {
|
|
249
|
+
const parent = useContext(CommandContext)
|
|
250
|
+
if (!parent) {
|
|
251
|
+
throw new Error("CommandSurfaceProvider must be used within a CommandProvider")
|
|
252
|
+
}
|
|
253
|
+
const { commandStore } = parent
|
|
151
254
|
|
|
152
|
-
|
|
153
|
-
|
|
255
|
+
// Surface-local mode changes are transitions too (F-08): a mid-chord mode
|
|
256
|
+
// switch on a modal surface resets the pending sequence.
|
|
257
|
+
const handleModeChange = useCallback(() => commandStore.modeChanged(id), [commandStore, id])
|
|
154
258
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
259
|
+
return (
|
|
260
|
+
<ModeProvider initialMode={initialMode} onModeChange={handleModeChange}>
|
|
261
|
+
<CommandSurfaceInner id={id} role={role}>
|
|
262
|
+
{children}
|
|
263
|
+
</CommandSurfaceInner>
|
|
264
|
+
</ModeProvider>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
158
267
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
268
|
+
function CommandSurfaceInner({
|
|
269
|
+
children,
|
|
270
|
+
id,
|
|
271
|
+
role,
|
|
272
|
+
}: {
|
|
273
|
+
children: ReactNode
|
|
274
|
+
id: string
|
|
275
|
+
role: CommandSurfaceRole
|
|
276
|
+
}) {
|
|
277
|
+
const parent = useContext(CommandContext)
|
|
278
|
+
if (!parent) {
|
|
279
|
+
throw new Error("CommandSurfaceProvider must be used within a CommandProvider")
|
|
280
|
+
}
|
|
281
|
+
const { commandStore } = parent
|
|
163
282
|
|
|
164
|
-
|
|
165
|
-
|
|
283
|
+
const parentDepth = useContext(CommandSurfaceDepthContext)
|
|
284
|
+
const depth = parentDepth + 1
|
|
166
285
|
|
|
167
|
-
|
|
286
|
+
const mode = useMode()
|
|
287
|
+
const setMode = useSetMode()
|
|
288
|
+
const modeRef = useRef(mode)
|
|
289
|
+
modeRef.current = mode
|
|
168
290
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
291
|
+
const buildCtx = useCallback((): CommandContext => {
|
|
292
|
+
const registry = commandStore.registryFor(recordRef.current!)
|
|
293
|
+
const ctx: Record<string, any> = {
|
|
294
|
+
mode: modeRef.current,
|
|
295
|
+
setMode,
|
|
296
|
+
commands: {
|
|
297
|
+
invoke: (cmdId: string) => registry.invoke(cmdId),
|
|
298
|
+
list: () => Array.from(registry.commands.values()),
|
|
299
|
+
},
|
|
300
|
+
exit: () => {},
|
|
174
301
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (multiStepCandidates.length > 0) {
|
|
178
|
-
const multiStepHotkeys = multiStepCandidates.map((candidate) => candidate.parsed)
|
|
179
|
-
const result = trackerRef.current.feedWithState(event, multiStepHotkeys)
|
|
180
|
-
if (result.matchedIndex >= 0) {
|
|
181
|
-
event.preventDefault()
|
|
182
|
-
setSequenceState(null)
|
|
183
|
-
multiStepCandidates[result.matchedIndex]!.command.handler(ctx)
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (result.pending) {
|
|
188
|
-
const firstCandidate = multiStepCandidates[result.pending.indexes[0]!]!
|
|
189
|
-
setSequenceState({
|
|
190
|
-
prefix: firstCandidate.parsed.steps.slice(0, result.pending.prefixLength),
|
|
191
|
-
candidates: result.pending.indexes
|
|
192
|
-
.map((idx) => multiStepCandidates[idx]!)
|
|
193
|
-
.filter(({ command }) => !command.hidden)
|
|
194
|
-
.map(({ command, hotkey, parsed }) => ({
|
|
195
|
-
command,
|
|
196
|
-
hotkey,
|
|
197
|
-
steps: parsed.steps,
|
|
198
|
-
remainingSteps: parsed.steps.slice(result.pending!.prefixLength),
|
|
199
|
-
nextStep: parsed.steps[result.pending!.prefixLength]!,
|
|
200
|
-
group: groupsRef.current.get(
|
|
201
|
-
stepsKey(parsed.steps.slice(0, result.pending!.prefixLength + 1)),
|
|
202
|
-
),
|
|
203
|
-
})),
|
|
204
|
-
})
|
|
205
|
-
event.preventDefault()
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
setSequenceState(null)
|
|
302
|
+
for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
|
|
303
|
+
Object.assign(ctx, getter())
|
|
210
304
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
305
|
+
return ctx as CommandContext
|
|
306
|
+
}, [commandStore, setMode])
|
|
307
|
+
|
|
308
|
+
const buildCtxRef = useRef(buildCtx)
|
|
309
|
+
buildCtxRef.current = buildCtx
|
|
310
|
+
|
|
311
|
+
const recordRef = useRef<SurfaceRecord | null>(null)
|
|
312
|
+
if (
|
|
313
|
+
recordRef.current === null ||
|
|
314
|
+
recordRef.current.id !== id ||
|
|
315
|
+
recordRef.current.role !== role ||
|
|
316
|
+
recordRef.current.depth !== depth
|
|
317
|
+
) {
|
|
318
|
+
recordRef.current = {
|
|
319
|
+
id,
|
|
320
|
+
role,
|
|
321
|
+
depth,
|
|
322
|
+
order: 0,
|
|
323
|
+
getMode: () => modeRef.current,
|
|
324
|
+
buildCtx: () => buildCtxRef.current(),
|
|
220
325
|
}
|
|
221
|
-
}
|
|
326
|
+
}
|
|
327
|
+
const record = recordRef.current
|
|
222
328
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}, [mode])
|
|
329
|
+
// Mount/unmount registration is a keep-effect: it synchronizes the React
|
|
330
|
+
// tree with the store's surface stack.
|
|
331
|
+
useEffect(() => commandStore.pushSurface(record), [commandStore, record])
|
|
227
332
|
|
|
228
|
-
const ctxValue = useMemo(
|
|
333
|
+
const ctxValue = useMemo<CommandStoreContextValue>(
|
|
229
334
|
() => ({
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
groups: groupsRef.current,
|
|
335
|
+
commandStore,
|
|
336
|
+
surface: record,
|
|
337
|
+
leaderKey: parent.leaderKey,
|
|
234
338
|
}),
|
|
235
|
-
[
|
|
339
|
+
[commandStore, record, parent.leaderKey],
|
|
236
340
|
)
|
|
237
341
|
|
|
238
342
|
return (
|
|
239
343
|
<CommandContext.Provider value={ctxValue}>
|
|
240
|
-
<
|
|
344
|
+
<CommandSurfaceDepthContext.Provider value={depth}>
|
|
345
|
+
{children}
|
|
346
|
+
</CommandSurfaceDepthContext.Provider>
|
|
241
347
|
</CommandContext.Provider>
|
|
242
348
|
)
|
|
243
349
|
}
|
|
@@ -247,14 +353,36 @@ export function useCommandContext(): { commands: Command[]; invoke: (id: string)
|
|
|
247
353
|
if (!ctx) {
|
|
248
354
|
throw new Error("useCommandContext must be used within a CommandProvider")
|
|
249
355
|
}
|
|
356
|
+
const { commandStore, surface } = ctx
|
|
250
357
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
358
|
+
// Reactive: consumers re-render when a command registers or unregisters on
|
|
359
|
+
// this surface (the per-surface map is identity-stable otherwise).
|
|
360
|
+
const commandMap = useSelector(commandStore.store, (s) =>
|
|
361
|
+
selectSurfaceCommandMap(s.context, surface.id),
|
|
362
|
+
)
|
|
363
|
+
const registry = commandStore.registryFor(surface)
|
|
364
|
+
|
|
365
|
+
return useMemo(
|
|
366
|
+
() => ({
|
|
367
|
+
commands: commandMap ? Array.from(commandMap.values()) : [],
|
|
368
|
+
invoke: registry.invoke,
|
|
369
|
+
}),
|
|
370
|
+
[commandMap, registry],
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Internal: the stable registry facade for the nearest surface, without
|
|
376
|
+
* subscribing to store changes. Registration hooks (useCommand/useActions)
|
|
377
|
+
* only need the facade; subscribing them would re-render every command host
|
|
378
|
+
* on unrelated group/context-source registrations.
|
|
379
|
+
*/
|
|
380
|
+
export function useSurfaceRegistry(): CommandRegistry {
|
|
381
|
+
const ctx = useContext(CommandContext)
|
|
382
|
+
if (!ctx) {
|
|
383
|
+
throw new Error("useCommandRegistry must be used within a CommandProvider")
|
|
257
384
|
}
|
|
385
|
+
return ctx.commandStore.registryFor(ctx.surface)
|
|
258
386
|
}
|
|
259
387
|
|
|
260
388
|
export function useCommandRegistry(): CommandContextValue {
|
|
@@ -262,13 +390,83 @@ export function useCommandRegistry(): CommandContextValue {
|
|
|
262
390
|
if (!ctx) {
|
|
263
391
|
throw new Error("useCommandRegistry must be used within a CommandProvider")
|
|
264
392
|
}
|
|
265
|
-
|
|
393
|
+
const { commandStore, surface, leaderKey } = ctx
|
|
394
|
+
|
|
395
|
+
// Subscribe to the slices so captured maps stay current across renders (the
|
|
396
|
+
// maps are immutable snapshots now, not shared mutable maps).
|
|
397
|
+
const groups = useSelector(commandStore.store, (s) => s.context.groups)
|
|
398
|
+
const contextSources = useSelector(commandStore.store, (s) => s.context.contextSources)
|
|
399
|
+
const registry = commandStore.registryFor(surface)
|
|
400
|
+
|
|
401
|
+
return useMemo(
|
|
402
|
+
() => ({
|
|
403
|
+
registry,
|
|
404
|
+
leaderKey,
|
|
405
|
+
contextSources: contextSources as Map<string, ContextGetter>,
|
|
406
|
+
groups: groups as Map<string, RegisteredCommandGroup>,
|
|
407
|
+
}),
|
|
408
|
+
[registry, leaderKey, contextSources, groups],
|
|
409
|
+
)
|
|
266
410
|
}
|
|
267
411
|
|
|
268
412
|
export function useCommandSequenceState(): CommandSequenceState | null {
|
|
269
413
|
return useContext(CommandSequenceContext)
|
|
270
414
|
}
|
|
271
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Metadata for the topmost modal command surface, or null when the root app is
|
|
418
|
+
* the active surface. Intended for which-key/help to read shortcuts from the
|
|
419
|
+
* active interaction surface. `commands` is reactive (F-13).
|
|
420
|
+
*/
|
|
421
|
+
export function useActiveCommandSurface(): ActiveCommandSurface | null {
|
|
422
|
+
const ctx = useContext(CommandContext)
|
|
423
|
+
const store = (ctx?.commandStore ?? FALLBACK_COMMAND_STORE).store
|
|
424
|
+
|
|
425
|
+
const record = useSelector(store, (s) => selectActiveModalSurface(s.context))
|
|
426
|
+
const commandMap = useSelector(store, (s) => {
|
|
427
|
+
const active = selectActiveModalSurface(s.context)
|
|
428
|
+
return active ? selectSurfaceCommandMap(s.context, active.id) : undefined
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
return useMemo(() => {
|
|
432
|
+
if (!record) return null
|
|
433
|
+
return {
|
|
434
|
+
id: record.id,
|
|
435
|
+
role: record.role as CommandSurfaceRole,
|
|
436
|
+
commands: commandMap ? Array.from(commandMap.values()) : [],
|
|
437
|
+
}
|
|
438
|
+
}, [record, commandMap])
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Commands registered on a surface (reactive). Defaults to the active modal
|
|
443
|
+
* surface, falling back to the root surface when none is active.
|
|
444
|
+
*/
|
|
445
|
+
export function useSurfaceCommands(surfaceId?: string): readonly Command[] {
|
|
446
|
+
const ctx = useContext(CommandContext)
|
|
447
|
+
const store = (ctx?.commandStore ?? FALLBACK_COMMAND_STORE).store
|
|
448
|
+
|
|
449
|
+
const commandMap = useSelector(store, (s) => {
|
|
450
|
+
const id = surfaceId ?? selectActiveModalSurface(s.context)?.id ?? ROOT_SURFACE_ID
|
|
451
|
+
return selectSurfaceCommandMap(s.context, id)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
return useMemo(() => (commandMap ? Array.from(commandMap.values()) : []), [commandMap])
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Advanced/internal — subject to change. The command store instance backing
|
|
459
|
+
* this provider tree, for bridges that must reach the dispatch machinery
|
|
460
|
+
* (e.g. the shell's overlay-replacement sequence reset).
|
|
461
|
+
*/
|
|
462
|
+
export function useCommandStore(): CommandStore {
|
|
463
|
+
const ctx = useContext(CommandContext)
|
|
464
|
+
if (!ctx) {
|
|
465
|
+
throw new Error("useCommandStore must be used within a CommandProvider")
|
|
466
|
+
}
|
|
467
|
+
return ctx.commandStore
|
|
468
|
+
}
|
|
469
|
+
|
|
272
470
|
export function useCommandGroup(group: CommandGroup): void {
|
|
273
471
|
const ctx = useContext(CommandContext)
|
|
274
472
|
if (!ctx) {
|
|
@@ -277,7 +475,7 @@ export function useCommandGroup(group: CommandGroup): void {
|
|
|
277
475
|
|
|
278
476
|
const groupRef = useRef(group)
|
|
279
477
|
groupRef.current = group
|
|
280
|
-
const {
|
|
478
|
+
const { commandStore, leaderKey } = ctx
|
|
281
479
|
|
|
282
480
|
useEffect(() => {
|
|
283
481
|
const parsed = parseHotkey(groupRef.current.prefix, leaderKey)
|
|
@@ -285,9 +483,10 @@ export function useCommandGroup(group: CommandGroup): void {
|
|
|
285
483
|
...groupRef.current,
|
|
286
484
|
prefixKey: stepsKey(parsed.steps),
|
|
287
485
|
}
|
|
288
|
-
|
|
486
|
+
commandStore.store.trigger.groupRegistered({ group: registered })
|
|
289
487
|
return () => {
|
|
290
|
-
|
|
488
|
+
// Unregistration is identity-guarded in the store transition (R-05).
|
|
489
|
+
commandStore.store.trigger.groupUnregistered({ group: registered })
|
|
291
490
|
}
|
|
292
491
|
}, [
|
|
293
492
|
group.id,
|
|
@@ -296,25 +495,11 @@ export function useCommandGroup(group: CommandGroup): void {
|
|
|
296
495
|
group.description,
|
|
297
496
|
group.icon,
|
|
298
497
|
group.order,
|
|
299
|
-
|
|
498
|
+
commandStore,
|
|
300
499
|
leaderKey,
|
|
301
500
|
])
|
|
302
501
|
}
|
|
303
502
|
|
|
304
|
-
function stepsKey(steps: ParsedStep[]): string {
|
|
305
|
-
return steps.map(formatStepKey).join(" ")
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function formatStepKey(step: ParsedStep): string {
|
|
309
|
-
const modifiers = []
|
|
310
|
-
if (step.ctrl) modifiers.push("ctrl")
|
|
311
|
-
if (step.meta) modifiers.push("meta")
|
|
312
|
-
if (step.option) modifiers.push("option")
|
|
313
|
-
if (step.shift) modifiers.push("shift")
|
|
314
|
-
modifiers.push(step.key)
|
|
315
|
-
return modifiers.join("+")
|
|
316
|
-
}
|
|
317
|
-
|
|
318
503
|
let nextContextSourceId = 0
|
|
319
504
|
|
|
320
505
|
export function useProvideCommandContext(getter: () => Partial<CommandContext>): void {
|
|
@@ -331,13 +516,16 @@ export function useProvideCommandContext(getter: () => Partial<CommandContext>):
|
|
|
331
516
|
const getterRef = useRef(getter)
|
|
332
517
|
getterRef.current = getter
|
|
333
518
|
|
|
334
|
-
const {
|
|
519
|
+
const { commandStore } = ctx
|
|
335
520
|
|
|
336
521
|
useEffect(() => {
|
|
337
522
|
const id = idRef.current!
|
|
338
|
-
|
|
523
|
+
commandStore.store.trigger.contextSourceRegistered({
|
|
524
|
+
id,
|
|
525
|
+
getter: () => getterRef.current(),
|
|
526
|
+
})
|
|
339
527
|
return () => {
|
|
340
|
-
|
|
528
|
+
commandStore.store.trigger.contextSourceUnregistered({ id })
|
|
341
529
|
}
|
|
342
|
-
}, [
|
|
530
|
+
}, [commandStore])
|
|
343
531
|
}
|