@tooee/commands 0.1.20 → 0.1.22
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 +54 -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 +29 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +192 -233
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +3 -1
- 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 +20 -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 +259 -276
- package/src/index.ts +22 -0
- 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 +22 -3
- package/src/use-actions.ts +5 -3
- package/src/use-command.ts +13 -4
package/src/context.tsx
CHANGED
|
@@ -5,10 +5,10 @@ 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
13
|
ActiveCommandSurface,
|
|
14
14
|
Command,
|
|
@@ -16,21 +16,23 @@ import type {
|
|
|
16
16
|
CommandGroup,
|
|
17
17
|
CommandRegistry,
|
|
18
18
|
CommandSequenceState,
|
|
19
|
-
CommandSurfaceEntry,
|
|
20
19
|
CommandSurfaceRole,
|
|
21
|
-
ParsedHotkey,
|
|
22
|
-
ParsedStep,
|
|
23
20
|
RegisteredCommandGroup,
|
|
24
21
|
} from "./types.js"
|
|
25
22
|
import type { Mode } from "./mode.js"
|
|
26
23
|
import { ModeProvider, useMode, useSetMode } from "./mode.js"
|
|
27
24
|
import { parseHotkey } from "./parse.js"
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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"
|
|
34
36
|
|
|
35
37
|
interface CommandContextValue {
|
|
36
38
|
registry: CommandRegistry
|
|
@@ -39,45 +41,37 @@ interface CommandContextValue {
|
|
|
39
41
|
groups: Map<string, RegisteredCommandGroup>
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
activeModalSurface: ActiveCommandSurface | null
|
|
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
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
const CommandContext = createContext<
|
|
52
|
+
const CommandContext = createContext<CommandStoreContextValue | null>(null)
|
|
52
53
|
const CommandSequenceContext = createContext<CommandSequenceState | null>(null)
|
|
53
|
-
const CommandSurfaceStackContext = createContext<CommandSurfaceStackValue | null>(null)
|
|
54
54
|
/** Nesting depth of the nearest command surface (0 at the root app). */
|
|
55
55
|
const CommandSurfaceDepthContext = createContext(0)
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* the current shared context sources.
|
|
58
|
+
* Fallback store for hooks that must not throw outside a CommandProvider
|
|
59
|
+
* (useActiveCommandSurface, useSurfaceCommands). Never dispatched to.
|
|
61
60
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
cmd.handler(ctx)
|
|
77
|
-
}
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
}
|
|
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
|
+
})
|
|
81
75
|
|
|
82
76
|
export interface CommandProviderProps {
|
|
83
77
|
children: ReactNode
|
|
@@ -87,6 +81,11 @@ export interface CommandProviderProps {
|
|
|
87
81
|
sequenceTimeoutMs?: number
|
|
88
82
|
}
|
|
89
83
|
|
|
84
|
+
interface RootAccess {
|
|
85
|
+
getMode: () => Mode
|
|
86
|
+
buildCtx: () => CommandContext
|
|
87
|
+
}
|
|
88
|
+
|
|
90
89
|
export function CommandProvider({
|
|
91
90
|
children,
|
|
92
91
|
leader,
|
|
@@ -94,9 +93,52 @@ export function CommandProvider({
|
|
|
94
93
|
initialMode,
|
|
95
94
|
sequenceTimeoutMs,
|
|
96
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
|
+
|
|
97
133
|
return (
|
|
98
|
-
<ModeProvider initialMode={initialMode}>
|
|
99
|
-
<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
|
+
>
|
|
100
142
|
{children}
|
|
101
143
|
</CommandDispatcher>
|
|
102
144
|
</ModeProvider>
|
|
@@ -105,227 +147,77 @@ export function CommandProvider({
|
|
|
105
147
|
|
|
106
148
|
function CommandDispatcher({
|
|
107
149
|
children,
|
|
150
|
+
commandStore,
|
|
151
|
+
rootAccess,
|
|
108
152
|
leader,
|
|
109
153
|
keymap,
|
|
110
154
|
sequenceTimeoutMs,
|
|
111
155
|
}: {
|
|
112
156
|
children: ReactNode
|
|
157
|
+
commandStore: CommandStore
|
|
158
|
+
rootAccess: { current: RootAccess }
|
|
113
159
|
leader?: string
|
|
114
160
|
keymap?: Record<string, string>
|
|
115
161
|
sequenceTimeoutMs?: number
|
|
116
162
|
}) {
|
|
117
|
-
const registryRef = useRef<CommandRegistry | null>(null)
|
|
118
|
-
const contextSourcesRef = useRef(new Map<string, ContextGetter>())
|
|
119
|
-
const groupsRef = useRef(new Map<string, RegisteredCommandGroup>())
|
|
120
163
|
const mode = useMode()
|
|
121
164
|
const modeRef = useRef(mode)
|
|
122
165
|
modeRef.current = mode
|
|
123
166
|
const setMode = useSetMode()
|
|
124
|
-
const [sequenceState, setSequenceState] = useState<CommandSequenceState | null>(null)
|
|
125
|
-
const clearSequenceStateRef = useRef(() => setSequenceState(null))
|
|
126
|
-
clearSequenceStateRef.current = () => setSequenceState(null)
|
|
127
167
|
|
|
128
168
|
const buildCtx = useCallback((): CommandContext => {
|
|
169
|
+
const registry = commandStore.registryFor(commandStore.rootRecord)
|
|
129
170
|
const ctx: Record<string, any> = {
|
|
130
171
|
mode: modeRef.current,
|
|
131
172
|
setMode,
|
|
132
173
|
commands: {
|
|
133
|
-
invoke: (id: string) =>
|
|
134
|
-
list: () => Array.from(
|
|
174
|
+
invoke: (id: string) => registry.invoke(id),
|
|
175
|
+
list: () => Array.from(registry.commands.values()),
|
|
135
176
|
},
|
|
136
177
|
exit: () => {},
|
|
137
178
|
}
|
|
138
|
-
for (const getter of
|
|
179
|
+
for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
|
|
139
180
|
Object.assign(ctx, getter())
|
|
140
181
|
}
|
|
141
182
|
return ctx as CommandContext
|
|
142
|
-
}, [setMode])
|
|
183
|
+
}, [commandStore, setMode])
|
|
143
184
|
|
|
144
185
|
const buildCtxRef = useRef(buildCtx)
|
|
145
186
|
buildCtxRef.current = buildCtx
|
|
146
187
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// The root registry above is the implicit base surface. Modal overlays push
|
|
153
|
-
// surfaces on top of it; while a modal surface is topmost, key dispatch is
|
|
154
|
-
// arbitrated to that surface only and the root (and lower surfaces) are
|
|
155
|
-
// suspended — even for keys the surface does not handle.
|
|
156
|
-
const surfaceEntriesRef = useRef<CommandSurfaceEntry[]>([])
|
|
157
|
-
const surfaceOrderRef = useRef(0)
|
|
158
|
-
const [surfaceVersion, setSurfaceVersion] = useState(0)
|
|
159
|
-
|
|
160
|
-
const registerSurface = useCallback((entry: CommandSurfaceEntry) => {
|
|
161
|
-
entry.order = surfaceOrderRef.current++
|
|
162
|
-
surfaceEntriesRef.current = [...surfaceEntriesRef.current, entry]
|
|
163
|
-
setSurfaceVersion((v) => v + 1)
|
|
164
|
-
return () => {
|
|
165
|
-
surfaceEntriesRef.current = surfaceEntriesRef.current.filter((e) => e !== entry)
|
|
166
|
-
setSurfaceVersion((v) => v + 1)
|
|
167
|
-
}
|
|
168
|
-
}, [])
|
|
169
|
-
|
|
170
|
-
const getActiveModalSurface = useCallback((): CommandSurfaceEntry | null => {
|
|
171
|
-
let best: CommandSurfaceEntry | null = null
|
|
172
|
-
for (const entry of surfaceEntriesRef.current) {
|
|
173
|
-
if (entry.role !== "modal") continue
|
|
174
|
-
if (
|
|
175
|
-
best === null ||
|
|
176
|
-
entry.depth > best.depth ||
|
|
177
|
-
(entry.depth === best.depth && entry.order > best.order)
|
|
178
|
-
) {
|
|
179
|
-
best = entry
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return best
|
|
183
|
-
}, [])
|
|
184
|
-
|
|
185
|
-
const activeModalSurface = useMemo<ActiveCommandSurface | null>(() => {
|
|
186
|
-
const surface = getActiveModalSurface()
|
|
187
|
-
return surface ? { id: surface.id, role: surface.role } : null
|
|
188
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
189
|
-
}, [getActiveModalSurface, surfaceVersion])
|
|
190
|
-
|
|
191
|
-
const surfaceStackValue = useMemo<CommandSurfaceStackValue>(
|
|
192
|
-
() => ({ register: registerSurface, getActiveModalSurface, activeModalSurface }),
|
|
193
|
-
[registerSurface, getActiveModalSurface, activeModalSurface],
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
const trackerRef = useRef(
|
|
197
|
-
new SequenceTracker({
|
|
198
|
-
timeout: sequenceTimeoutMs,
|
|
199
|
-
onReset: () => clearSequenceStateRef.current(),
|
|
200
|
-
}),
|
|
201
|
-
)
|
|
202
|
-
const parseCacheRef = useRef(new Map<string, ParsedHotkey>())
|
|
203
|
-
|
|
204
|
-
const getParsedHotkey = useCallback(
|
|
205
|
-
(hotkey: string) => {
|
|
206
|
-
const cache = parseCacheRef.current
|
|
207
|
-
const cacheKey = `${hotkey}:${leader ?? ""}`
|
|
208
|
-
let parsed = cache.get(cacheKey)
|
|
209
|
-
if (!parsed) {
|
|
210
|
-
parsed = parseHotkey(hotkey, leader)
|
|
211
|
-
cache.set(cacheKey, parsed)
|
|
212
|
-
}
|
|
213
|
-
return parsed
|
|
214
|
-
},
|
|
215
|
-
[leader],
|
|
216
|
-
)
|
|
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 })
|
|
217
193
|
|
|
218
194
|
useKeyboard((event) => {
|
|
219
195
|
if (event.defaultPrevented) return
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// Arbitration: a topmost modal surface owns input; otherwise the root app.
|
|
225
|
-
const activeSurface = getActiveModalSurface()
|
|
226
|
-
const registry = activeSurface ? activeSurface.registry : rootRegistry
|
|
227
|
-
const currentMode = activeSurface ? activeSurface.getMode() : modeRef.current
|
|
228
|
-
const ctx = activeSurface ? activeSurface.buildCtx() : buildCtx()
|
|
229
|
-
|
|
230
|
-
// Collect eligible commands with their parsed hotkeys
|
|
231
|
-
const singleStepCandidates: { command: Command; parsed: ParsedHotkey }[] = []
|
|
232
|
-
const multiStepCandidates: { command: Command; hotkey: string; parsed: ParsedHotkey }[] = []
|
|
233
|
-
|
|
234
|
-
for (const command of registry.commands.values()) {
|
|
235
|
-
const commandModes = command.modes ?? DEFAULT_MODES
|
|
236
|
-
if (!commandModes.includes(currentMode)) continue
|
|
237
|
-
if (command.when && !command.when(ctx)) continue
|
|
238
|
-
|
|
239
|
-
const hotkey = keymap?.[command.id] ?? command.defaultHotkey
|
|
240
|
-
if (!hotkey) continue
|
|
241
|
-
|
|
242
|
-
const parsed = getParsedHotkey(hotkey)
|
|
243
|
-
|
|
244
|
-
if (parsed.steps.length === 1) {
|
|
245
|
-
singleStepCandidates.push({ command, parsed })
|
|
246
|
-
} else {
|
|
247
|
-
multiStepCandidates.push({ command, hotkey, parsed })
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Check multi-step sequences first (they consume buffer state)
|
|
252
|
-
if (multiStepCandidates.length > 0) {
|
|
253
|
-
const multiStepHotkeys = multiStepCandidates.map((candidate) => candidate.parsed)
|
|
254
|
-
const result = trackerRef.current.feedWithState(event, multiStepHotkeys)
|
|
255
|
-
if (result.matchedIndex >= 0) {
|
|
256
|
-
event.preventDefault()
|
|
257
|
-
setSequenceState(null)
|
|
258
|
-
multiStepCandidates[result.matchedIndex]!.command.handler(ctx)
|
|
259
|
-
return
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (result.pending) {
|
|
263
|
-
const firstCandidate = multiStepCandidates[result.pending.indexes[0]!]!
|
|
264
|
-
setSequenceState({
|
|
265
|
-
prefix: firstCandidate.parsed.steps.slice(0, result.pending.prefixLength),
|
|
266
|
-
candidates: result.pending.indexes
|
|
267
|
-
.map((idx) => multiStepCandidates[idx]!)
|
|
268
|
-
.filter(({ command }) => !command.hidden)
|
|
269
|
-
.map(({ command, hotkey, parsed }) => ({
|
|
270
|
-
command,
|
|
271
|
-
hotkey,
|
|
272
|
-
steps: parsed.steps,
|
|
273
|
-
remainingSteps: parsed.steps.slice(result.pending!.prefixLength),
|
|
274
|
-
nextStep: parsed.steps[result.pending!.prefixLength]!,
|
|
275
|
-
group: groupsRef.current.get(
|
|
276
|
-
stepsKey(parsed.steps.slice(0, result.pending!.prefixLength + 1)),
|
|
277
|
-
),
|
|
278
|
-
})),
|
|
279
|
-
})
|
|
280
|
-
event.preventDefault()
|
|
281
|
-
return
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
setSequenceState(null)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Check single-step matches
|
|
288
|
-
for (const { command, parsed } of singleStepCandidates) {
|
|
289
|
-
if (matchStep(event, parsed.steps[0]!)) {
|
|
290
|
-
event.preventDefault()
|
|
291
|
-
setSequenceState(null)
|
|
292
|
-
command.handler(ctx)
|
|
293
|
-
return
|
|
294
|
-
}
|
|
196
|
+
const result = commandStore.key(event)
|
|
197
|
+
if (result.handled) {
|
|
198
|
+
event.preventDefault()
|
|
199
|
+
result.invoke?.()
|
|
295
200
|
}
|
|
296
|
-
|
|
297
|
-
// A modal surface swallows unhandled keys: dispatch never falls through to
|
|
298
|
-
// the root app while a modal surface is topmost.
|
|
299
201
|
})
|
|
300
202
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}, [mode])
|
|
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])
|
|
305
206
|
|
|
306
|
-
|
|
307
|
-
// surface opens/closes) so partial chords don't leak across surfaces. Passive
|
|
308
|
-
// surfaces such as which-key must not clear the sequence they are displaying.
|
|
309
|
-
useEffect(() => {
|
|
310
|
-
trackerRef.current.reset()
|
|
311
|
-
setSequenceState(null)
|
|
312
|
-
}, [activeModalSurface?.id])
|
|
207
|
+
const sequenceState = useSelector(commandStore.store, (s) => selectSequence(s.context))
|
|
313
208
|
|
|
314
|
-
const ctxValue = useMemo(
|
|
209
|
+
const ctxValue = useMemo<CommandStoreContextValue>(
|
|
315
210
|
() => ({
|
|
316
|
-
|
|
211
|
+
commandStore,
|
|
212
|
+
surface: commandStore.rootRecord,
|
|
317
213
|
leaderKey: leader,
|
|
318
|
-
contextSources: contextSourcesRef.current,
|
|
319
|
-
groups: groupsRef.current,
|
|
320
214
|
}),
|
|
321
|
-
[leader],
|
|
215
|
+
[commandStore, leader],
|
|
322
216
|
)
|
|
323
217
|
|
|
324
218
|
return (
|
|
325
219
|
<CommandContext.Provider value={ctxValue}>
|
|
326
|
-
<
|
|
327
|
-
<CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
|
|
328
|
-
</CommandSurfaceStackContext.Provider>
|
|
220
|
+
<CommandSequenceContext value={sequenceState}>{children}</CommandSequenceContext>
|
|
329
221
|
</CommandContext.Provider>
|
|
330
222
|
)
|
|
331
223
|
}
|
|
@@ -354,8 +246,18 @@ export function CommandSurfaceProvider({
|
|
|
354
246
|
role = "modal",
|
|
355
247
|
initialMode = "cursor",
|
|
356
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
|
|
254
|
+
|
|
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])
|
|
258
|
+
|
|
357
259
|
return (
|
|
358
|
-
<ModeProvider initialMode={initialMode}>
|
|
260
|
+
<ModeProvider initialMode={initialMode} onModeChange={handleModeChange}>
|
|
359
261
|
<CommandSurfaceInner id={id} role={role}>
|
|
360
262
|
{children}
|
|
361
263
|
</CommandSurfaceInner>
|
|
@@ -373,10 +275,10 @@ function CommandSurfaceInner({
|
|
|
373
275
|
role: CommandSurfaceRole
|
|
374
276
|
}) {
|
|
375
277
|
const parent = useContext(CommandContext)
|
|
376
|
-
|
|
377
|
-
if (!parent || !stack) {
|
|
278
|
+
if (!parent) {
|
|
378
279
|
throw new Error("CommandSurfaceProvider must be used within a CommandProvider")
|
|
379
280
|
}
|
|
281
|
+
const { commandStore } = parent
|
|
380
282
|
|
|
381
283
|
const parentDepth = useContext(CommandSurfaceDepthContext)
|
|
382
284
|
const depth = parentDepth + 1
|
|
@@ -386,53 +288,55 @@ function CommandSurfaceInner({
|
|
|
386
288
|
const modeRef = useRef(mode)
|
|
387
289
|
modeRef.current = mode
|
|
388
290
|
|
|
389
|
-
const registryRef = useRef<CommandRegistry | null>(null)
|
|
390
|
-
|
|
391
291
|
const buildCtx = useCallback((): CommandContext => {
|
|
292
|
+
const registry = commandStore.registryFor(recordRef.current!)
|
|
392
293
|
const ctx: Record<string, any> = {
|
|
393
294
|
mode: modeRef.current,
|
|
394
295
|
setMode,
|
|
395
296
|
commands: {
|
|
396
|
-
invoke: (cmdId: string) =>
|
|
397
|
-
list: () => Array.from(
|
|
297
|
+
invoke: (cmdId: string) => registry.invoke(cmdId),
|
|
298
|
+
list: () => Array.from(registry.commands.values()),
|
|
398
299
|
},
|
|
399
300
|
exit: () => {},
|
|
400
301
|
}
|
|
401
|
-
for (const getter of
|
|
302
|
+
for (const getter of commandStore.store.getSnapshot().context.contextSources.values()) {
|
|
402
303
|
Object.assign(ctx, getter())
|
|
403
304
|
}
|
|
404
305
|
return ctx as CommandContext
|
|
405
|
-
}, [
|
|
306
|
+
}, [commandStore, setMode])
|
|
406
307
|
|
|
407
308
|
const buildCtxRef = useRef(buildCtx)
|
|
408
309
|
buildCtxRef.current = buildCtx
|
|
409
310
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 = {
|
|
417
319
|
id,
|
|
418
320
|
role,
|
|
419
321
|
depth,
|
|
420
322
|
order: 0,
|
|
421
|
-
registry: registryRef.current!,
|
|
422
323
|
getMode: () => modeRef.current,
|
|
423
324
|
buildCtx: () => buildCtxRef.current(),
|
|
424
325
|
}
|
|
425
|
-
|
|
426
|
-
|
|
326
|
+
}
|
|
327
|
+
const record = recordRef.current
|
|
328
|
+
|
|
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])
|
|
427
332
|
|
|
428
|
-
const ctxValue = useMemo<
|
|
333
|
+
const ctxValue = useMemo<CommandStoreContextValue>(
|
|
429
334
|
() => ({
|
|
430
|
-
|
|
335
|
+
commandStore,
|
|
336
|
+
surface: record,
|
|
431
337
|
leaderKey: parent.leaderKey,
|
|
432
|
-
contextSources: parent.contextSources,
|
|
433
|
-
groups: parent.groups,
|
|
434
338
|
}),
|
|
435
|
-
[
|
|
339
|
+
[commandStore, record, parent.leaderKey],
|
|
436
340
|
)
|
|
437
341
|
|
|
438
342
|
return (
|
|
@@ -449,14 +353,36 @@ export function useCommandContext(): { commands: Command[]; invoke: (id: string)
|
|
|
449
353
|
if (!ctx) {
|
|
450
354
|
throw new Error("useCommandContext must be used within a CommandProvider")
|
|
451
355
|
}
|
|
356
|
+
const { commandStore, surface } = ctx
|
|
357
|
+
|
|
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
|
+
}
|
|
452
373
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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")
|
|
459
384
|
}
|
|
385
|
+
return ctx.commandStore.registryFor(ctx.surface)
|
|
460
386
|
}
|
|
461
387
|
|
|
462
388
|
export function useCommandRegistry(): CommandContextValue {
|
|
@@ -464,7 +390,23 @@ export function useCommandRegistry(): CommandContextValue {
|
|
|
464
390
|
if (!ctx) {
|
|
465
391
|
throw new Error("useCommandRegistry must be used within a CommandProvider")
|
|
466
392
|
}
|
|
467
|
-
|
|
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
|
+
)
|
|
468
410
|
}
|
|
469
411
|
|
|
470
412
|
export function useCommandSequenceState(): CommandSequenceState | null {
|
|
@@ -474,11 +416,55 @@ export function useCommandSequenceState(): CommandSequenceState | null {
|
|
|
474
416
|
/**
|
|
475
417
|
* Metadata for the topmost modal command surface, or null when the root app is
|
|
476
418
|
* the active surface. Intended for which-key/help to read shortcuts from the
|
|
477
|
-
* active interaction surface.
|
|
419
|
+
* active interaction surface. `commands` is reactive (F-13).
|
|
478
420
|
*/
|
|
479
421
|
export function useActiveCommandSurface(): ActiveCommandSurface | null {
|
|
480
|
-
const ctx = useContext(
|
|
481
|
-
|
|
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
|
|
482
468
|
}
|
|
483
469
|
|
|
484
470
|
export function useCommandGroup(group: CommandGroup): void {
|
|
@@ -489,7 +475,7 @@ export function useCommandGroup(group: CommandGroup): void {
|
|
|
489
475
|
|
|
490
476
|
const groupRef = useRef(group)
|
|
491
477
|
groupRef.current = group
|
|
492
|
-
const {
|
|
478
|
+
const { commandStore, leaderKey } = ctx
|
|
493
479
|
|
|
494
480
|
useEffect(() => {
|
|
495
481
|
const parsed = parseHotkey(groupRef.current.prefix, leaderKey)
|
|
@@ -497,9 +483,10 @@ export function useCommandGroup(group: CommandGroup): void {
|
|
|
497
483
|
...groupRef.current,
|
|
498
484
|
prefixKey: stepsKey(parsed.steps),
|
|
499
485
|
}
|
|
500
|
-
|
|
486
|
+
commandStore.store.trigger.groupRegistered({ group: registered })
|
|
501
487
|
return () => {
|
|
502
|
-
|
|
488
|
+
// Unregistration is identity-guarded in the store transition (R-05).
|
|
489
|
+
commandStore.store.trigger.groupUnregistered({ group: registered })
|
|
503
490
|
}
|
|
504
491
|
}, [
|
|
505
492
|
group.id,
|
|
@@ -508,25 +495,11 @@ export function useCommandGroup(group: CommandGroup): void {
|
|
|
508
495
|
group.description,
|
|
509
496
|
group.icon,
|
|
510
497
|
group.order,
|
|
511
|
-
|
|
498
|
+
commandStore,
|
|
512
499
|
leaderKey,
|
|
513
500
|
])
|
|
514
501
|
}
|
|
515
502
|
|
|
516
|
-
function stepsKey(steps: ParsedStep[]): string {
|
|
517
|
-
return steps.map(formatStepKey).join(" ")
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function formatStepKey(step: ParsedStep): string {
|
|
521
|
-
const modifiers = []
|
|
522
|
-
if (step.ctrl) modifiers.push("ctrl")
|
|
523
|
-
if (step.meta) modifiers.push("meta")
|
|
524
|
-
if (step.option) modifiers.push("option")
|
|
525
|
-
if (step.shift) modifiers.push("shift")
|
|
526
|
-
modifiers.push(step.key)
|
|
527
|
-
return modifiers.join("+")
|
|
528
|
-
}
|
|
529
|
-
|
|
530
503
|
let nextContextSourceId = 0
|
|
531
504
|
|
|
532
505
|
export function useProvideCommandContext(getter: () => Partial<CommandContext>): void {
|
|
@@ -543,13 +516,23 @@ export function useProvideCommandContext(getter: () => Partial<CommandContext>):
|
|
|
543
516
|
const getterRef = useRef(getter)
|
|
544
517
|
getterRef.current = getter
|
|
545
518
|
|
|
546
|
-
const {
|
|
519
|
+
const { commandStore } = ctx
|
|
547
520
|
|
|
548
521
|
useEffect(() => {
|
|
549
522
|
const id = idRef.current!
|
|
550
|
-
|
|
523
|
+
commandStore.store.trigger.contextSourceRegistered({
|
|
524
|
+
id,
|
|
525
|
+
getter: () => getterRef.current(),
|
|
526
|
+
})
|
|
551
527
|
return () => {
|
|
552
|
-
|
|
528
|
+
commandStore.store.trigger.contextSourceUnregistered({ id })
|
|
553
529
|
}
|
|
554
|
-
}, [
|
|
530
|
+
}, [commandStore])
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function useProvideCommandContextKey<K extends keyof CommandContext>(
|
|
534
|
+
key: K,
|
|
535
|
+
getter: () => CommandContext[K],
|
|
536
|
+
): void {
|
|
537
|
+
useProvideCommandContext(() => ({ [key]: getter() }) as Pick<CommandContext, K>)
|
|
555
538
|
}
|