Skip to content

Commit 6d8cc6e

Browse files
committed
feat(key-shortcuts): registry contract and command-declared shortcuts
KeyShortcutRegistry is a contract with disposable registrations; the engine resolves help and dispatch through it, so an entry registered at runtime takes effect immediately. The shared entries become builders, a defineCommand may declare the keys it answers to, and ns run and ns debug get their own tables behind NS_COMMAND_SHORTCUTS (default off).
1 parent 83381c5 commit 6d8cc6e

15 files changed

Lines changed: 754 additions & 103 deletions

lib/bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ injector.require("tempService", "./services/temp-service");
721721

722722
injector.require("sharedEventBus", "./shared-event-bus");
723723

724+
injector.require("keyShortcutRegistry", "./services/key-shortcut-registry");
724725
injector.require("keyShortcutService", "./services/key-shortcuts");
725726

726727
registerBuiltInCommand<

lib/commands/debug.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IErrors, ISysInfo } from "../common/declarations";
2+
import { commandShortcutsEnabled } from "../common/contracts/key-shortcuts";
23
import {
34
booleanOption,
45
CommandContext,
@@ -18,6 +19,12 @@ import {
1819
} from "../definitions/debug";
1920
import { IMigrateController } from "../definitions/migrate";
2021
import { SystemWarningsSeverity } from "../definitions/system-warnings";
22+
import {
23+
IKeyShortcutService,
24+
KeyShortcutRegistry,
25+
restartShortcut,
26+
watcherShortcut,
27+
} from "../services/key-shortcuts";
2128
import {
2229
canExecuteCommandBase,
2330
injectPlatformCommandServices,
@@ -152,17 +159,53 @@ export async function runDebugCommand(
152159
return;
153160
}
154161

162+
const liveSyncOptions = (
163+
additional: Partial<ILiveSyncCommandHelperAdditionalOptions>,
164+
): ILiveSyncCommandHelperAdditionalOptions => ({
165+
deviceDebugMap: {
166+
[selectedDeviceForDebug.deviceInfo.identifier]: true,
167+
},
168+
buildPlatform: undefined,
169+
skipNativePrepare: false,
170+
...additional,
171+
});
172+
155173
await services.$liveSyncCommandHelper.executeLiveSyncOperation(
156174
[selectedDeviceForDebug],
157175
services.platform,
158-
{
159-
deviceDebugMap: {
160-
[selectedDeviceForDebug.deviceInfo.identifier]: true,
161-
},
162-
buildPlatform: undefined,
163-
skipNativePrepare: false,
164-
},
176+
liveSyncOptions({}),
165177
);
178+
179+
if (!commandShortcutsEnabled()) {
180+
return;
181+
}
182+
183+
// The device map is what keeps the debugger attached across a restart, so
184+
// the shared restart — which knows nothing of it — cannot stand in here.
185+
const restartDebugSession = (forceRebuildNativeApp: boolean): Promise<void> =>
186+
services.$liveSyncCommandHelper.executeLiveSyncOperation(
187+
[selectedDeviceForDebug],
188+
services.platform,
189+
liveSyncOptions(<Partial<ILiveSyncCommandHelperAdditionalOptions>>{
190+
restartLiveSync: true,
191+
...(forceRebuildNativeApp ? { forceRebuildNativeApp: true } : {}),
192+
}),
193+
);
194+
195+
context.injector.get(KeyShortcutRegistry).add(
196+
restartShortcut({ restart: restartDebugSession }),
197+
restartShortcut({
198+
forceRebuildNativeApp: true,
199+
restart: restartDebugSession,
200+
}),
201+
watcherShortcut(),
202+
);
203+
204+
const keyShortcutService =
205+
context.injector.get<IKeyShortcutService>("keyShortcutService");
206+
if (keyShortcutService.attach({ shortcuts: [] })) {
207+
keyShortcutService.printHint();
208+
}
166209
}
167210

168211
interface IDebugApplePlatformCommandServices extends IDebugCommandServices {

lib/commands/run.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import { IProjectData, IProjectDataService } from "../definitions/project";
2020
import {
2121
DevicePlatformName,
2222
IKeyShortcutService,
23+
KeyShortcut,
2324
keyShortcuts,
25+
restartShortcut,
26+
watcherShortcut,
2427
} from "../services/key-shortcuts";
2528

2629
const runCommandOptions = {
@@ -137,6 +140,30 @@ export async function runRunCommand(
137140
}
138141
}
139142

143+
/**
144+
* Restarting and pausing the watcher are the shortcuts a standalone run owns
145+
* outright; the launch and clean keys belong to the parent that respawns
146+
* things, which is why the `ns start` table is not reused here.
147+
*/
148+
export function runCommandShortcuts(
149+
context: RunCommandContext,
150+
services: IRunCommandServices,
151+
): KeyShortcut[] {
152+
if (process.env.NS_IS_INTERACTIVE) {
153+
// A `ns start` child is driven over IPC through the table `run` attaches
154+
// for itself; a second attach would replace it.
155+
return [];
156+
}
157+
158+
const platform = <DevicePlatformName>services.platform;
159+
160+
return [
161+
restartShortcut({ platform }),
162+
restartShortcut({ platform, forceRebuildNativeApp: true }),
163+
watcherShortcut(),
164+
];
165+
}
166+
140167
export const runCommandDefinition = defineCommand({
141168
name: "run|*all",
142169
description: "Runs your project on all connected devices and emulators.",
@@ -146,6 +173,7 @@ export const runCommandDefinition = defineCommand({
146173
setup: setupRunCommand,
147174
canExecute: canExecuteRunCommand,
148175
run: runRunCommand,
176+
shortcuts: runCommandShortcuts,
149177
});
150178

151179
async function canExecuteApplePlatformRunCommand(
@@ -188,6 +216,7 @@ const defineApplePlatformRunCommand = <const TName extends CommandName>(
188216
setup: setupPlatformRunCommand(platform),
189217
canExecute: canExecuteApplePlatformRunCommand,
190218
run: runRunCommand,
219+
shortcuts: runCommandShortcuts,
191220
});
192221

193222
export const iosRunCommand = defineApplePlatformRunCommand("run|ios", "iOS");
@@ -242,4 +271,5 @@ export const androidRunCommand = defineCommand({
242271
);
243272
},
244273
run: runRunCommand,
274+
shortcuts: runCommandShortcuts,
245275
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Contract } from "../di/contract";
2+
import type { Injector } from "../di/injector";
3+
4+
/**
5+
* What every shortcut can count on. The context carries state; capabilities
6+
* come from the injector. Callers extend it with the dimensions their own
7+
* tables ask about — nothing in the engine inspects the context beyond handing
8+
* it to `when` and `action`.
9+
*/
10+
export interface KeyContextBase {
11+
injector: Injector;
12+
}
13+
14+
/** The half of a context its caller owns; the service provides the rest. */
15+
export type KeyContextExtras<TContext extends KeyContextBase> = Omit<
16+
TContext,
17+
keyof KeyContextBase
18+
>;
19+
20+
export interface KeyShortcut<TContext extends KeyContextBase = KeyContextBase> {
21+
key: string;
22+
description: string;
23+
group?: string;
24+
/** Availability AND help visibility — one verdict feeds both. */
25+
when?(ctx: TContext): boolean;
26+
action?(ctx: TContext): void | Promise<void>;
27+
/**
28+
* Suppresses the keypress banner. Set by shortcuts that hand the key to a
29+
* child process, which announces and runs it itself.
30+
*/
31+
quiet?: boolean;
32+
}
33+
34+
export interface IKeyShortcutService {
35+
/** Returns false when the terminal cannot take raw mode. */
36+
attach<TContext extends KeyContextBase = KeyContextBase>(options: {
37+
context?: KeyContextExtras<TContext>;
38+
shortcuts: KeyShortcut<TContext>[];
39+
}): boolean;
40+
detach(): void;
41+
printHelp(): void;
42+
printHint(): void;
43+
}
44+
45+
/** What `add` hands back; the only way to take a registration out again. */
46+
export interface KeyShortcutRegistration {
47+
dispose(): void;
48+
}
49+
50+
/**
51+
* The shortcuts the running process answers to. Registrations are owned by
52+
* whoever made them: attaching and detaching the engine disposes only the
53+
* batch attach itself registered, so entries a lifecycle registered on its own
54+
* survive until that lifecycle disposes them.
55+
*/
56+
@Contract({ name: "keyShortcutRegistry" })
57+
export abstract class KeyShortcutRegistry {
58+
/** Later registrations shadow earlier ones per key; disposing restores what was shadowed. */
59+
abstract add(...shortcuts: KeyShortcut[]): KeyShortcutRegistration;
60+
/**
61+
* Every entry in registration order. The dedupe by key is the reader's, so
62+
* that a disposal exposes what it shadowed without the registry tracking it.
63+
*/
64+
abstract entries(): KeyShortcut[];
65+
}
66+
67+
const OFF_VALUES = ["0", "false", "off", "no"];
68+
69+
/** Reads an env switch by the convention `NS_KEY_SHORTCUTS` established. */
70+
export function envSwitchIsOn(value: string): boolean {
71+
return value !== undefined && !OFF_VALUES.includes(value.toLowerCase());
72+
}
73+
74+
/**
75+
* Whether a command's declared `shortcuts` are attached when it runs. Off
76+
* unless `NS_COMMAND_SHORTCUTS` says otherwise: a command that takes the
77+
* terminal into raw mode and stays resident is not what a plain `ns run` or
78+
* `ns debug` has ever done.
79+
*/
80+
export function commandShortcutsEnabled(): boolean {
81+
return envSwitchIsOn(process.env.NS_COMMAND_SHORTCUTS);
82+
}

lib/common/define-command.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* lib/common/services/command-definition-adapter.
77
*/
88

9+
import type { KeyShortcut } from "./contracts/key-shortcuts";
910
import type { Injector } from "./di/injector";
1011

1112
/**
@@ -159,6 +160,17 @@ export interface CommandDefinition<
159160
context: CommandContext<TSchema>,
160161
setupResult: Awaited<TSetup>,
161162
): TResult | Promise<TResult>;
163+
/**
164+
* The keys the command answers to once `run` has resolved. Attaching keeps
165+
* stdin resumed, which keeps the process alive: declaring shortcuts says the
166+
* command is resident. Entries close over this command's own context and
167+
* setup result; they are attached only for a top-level run, and only while
168+
* `NS_COMMAND_SHORTCUTS` is on.
169+
*/
170+
shortcuts?(
171+
context: CommandContext<TSchema>,
172+
setupResult: Awaited<TSetup>,
173+
): KeyShortcut[];
162174
/** Runs after `run` succeeds, with whatever `run` returned. */
163175
postRun?(
164176
context: CommandContext<TSchema>,
@@ -211,6 +223,7 @@ const DEFINITION_FIELDS = [
211223
"enableHooks",
212224
"setup",
213225
"run",
226+
"shortcuts",
214227
"postRun",
215228
];
216229

@@ -242,7 +255,7 @@ const OPTION_TYPES: CommandOptionType[] = [
242255
const ACCEPTED_FORM =
243256
'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' +
244257
"optional fields description, options, arguments, allowUnknownOptions, " +
245-
"setup, canExecute, postRun, disableAnalytics and enableHooks.";
258+
"setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks.";
246259

247260
const describeDefinition = (definition: any): string => {
248261
const name = definition && definition.name;
@@ -475,7 +488,7 @@ const validateDefinition = (definition: any): void => {
475488
}
476489
}
477490

478-
for (const handler of ["canExecute", "setup", "postRun"]) {
491+
for (const handler of ["canExecute", "setup", "shortcuts", "postRun"]) {
479492
if (
480493
definition[handler] !== undefined &&
481494
typeof definition[handler] !== "function"

lib/common/definitions/commands-service.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
interface ICommandsService {
22
currentCommandData: ICommandData;
3+
/**
4+
* Whether the command running right now was dispatched by
5+
* executeCommandInProcess rather than by the command line — what tells a
6+
* command that it is borrowing a host process instead of owning one.
7+
*/
8+
readonly isExecutingInProcess: boolean;
39
allCommands(opts: { includeDevCommands: boolean }): string[];
410
tryExecuteCommand(
511
commandName: string,

lib/common/services/command-definition-adapter.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111
DeferredCommandResult,
1212
describeRejection,
1313
} from "../contracts/command-registry";
14+
import {
15+
commandShortcutsEnabled,
16+
IKeyShortcutService,
17+
KeyShortcut,
18+
} from "../contracts/key-shortcuts";
1419
import { Provider } from "../di/providers";
1520
import {
1621
ArgumentSpec,
@@ -365,6 +370,46 @@ export function createCommandFromDefinition<
365370
return currentInvocation;
366371
};
367372

373+
/**
374+
* Attaching takes the terminal into raw mode and leaves stdin resumed, so it
375+
* is confined to a top-level run: an in-process dispatch borrows the
376+
* terminal of a host that has its own table attached, and replacing it would
377+
* take the host's keys with it.
378+
*/
379+
const attachShortcuts = (
380+
context: CommandContext<TSchema>,
381+
setupResult: Awaited<TSetup>,
382+
): void => {
383+
if (!commandShortcutsEnabled()) {
384+
return;
385+
}
386+
387+
const commandsService = targetInjector.get<ICommandsService>(
388+
"commandsService",
389+
{ optional: true },
390+
);
391+
if (commandsService && commandsService.isExecutingInProcess) {
392+
return;
393+
}
394+
395+
const shortcuts: KeyShortcut[] = runInInjectionContext(targetInjector, () =>
396+
definition.shortcuts.call(definition, context, setupResult),
397+
);
398+
if (!shortcuts || !shortcuts.length) {
399+
return;
400+
}
401+
402+
const keyShortcutService = targetInjector.get<IKeyShortcutService>(
403+
"keyShortcutService",
404+
{ optional: true },
405+
);
406+
if (!keyShortcutService || !keyShortcutService.attach({ shortcuts })) {
407+
return;
408+
}
409+
410+
keyShortcutService.printHint();
411+
};
412+
368413
return {
369414
allowedParameters: [],
370415
dashedOptions,
@@ -428,6 +473,10 @@ export function createCommandFromDefinition<
428473
invocation.runResult = await runInInjectionContext(targetInjector, () =>
429474
definition.run.call(definition, context, setupResult),
430475
);
476+
477+
if (definition.shortcuts) {
478+
attachShortcuts(context, setupResult);
479+
}
431480
},
432481
};
433482
}

lib/common/services/commands-service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export class CommandsService implements ICommandsService {
3333
}
3434

3535
private commands: ICommandData[] = [];
36+
private inProcessDepth: number = 0;
37+
38+
public get isExecutingInProcess(): boolean {
39+
return this.inProcessDepth > 0;
40+
}
3641

3742
constructor(
3843
private $errors: IErrors,
@@ -238,6 +243,7 @@ export class CommandsService implements ICommandsService {
238243
commandName: string,
239244
commandArguments: string[] = [],
240245
): Promise<void> {
246+
this.inProcessDepth++;
241247
try {
242248
const command = this.$injector.resolveCommand(commandName);
243249
if (!command) {
@@ -272,6 +278,8 @@ export class CommandsService implements ICommandsService {
272278
);
273279

274280
throw ex;
281+
} finally {
282+
this.inProcessDepth--;
275283
}
276284
}
277285

0 commit comments

Comments
 (0)