@narumitw/pi-sync 0.42.0 → 0.46.0
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/package.json +2 -2
- package/src/cancellable-operation.ts +116 -0
- package/src/manager-ui.ts +56 -83
- package/src/secret-input.ts +73 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"typecheck": "tsc --noEmit"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
39
|
+
"@narumitw/pi-tui-kit": "^0.45.0",
|
|
40
40
|
"fast-xml-parser": "^5.10.1",
|
|
41
41
|
"proper-lockfile": "^4.1.2"
|
|
42
42
|
},
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BorderedLoader,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
type KeybindingsManager,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
8
|
+
import { safeTerminalText } from "./manager-helpers.js";
|
|
9
|
+
import type { SetupPullOutcome } from "./setup-switch.js";
|
|
10
|
+
|
|
11
|
+
export type RunRoute = (
|
|
12
|
+
route: string,
|
|
13
|
+
signal?: AbortSignal,
|
|
14
|
+
onCommit?: () => void,
|
|
15
|
+
target?: string,
|
|
16
|
+
) => Promise<SetupPullOutcome | undefined>;
|
|
17
|
+
|
|
18
|
+
export type CancellableOperationResult = SetupPullOutcome | "closed" | undefined;
|
|
19
|
+
|
|
20
|
+
interface CancellableOperationOptions {
|
|
21
|
+
commitAware?: boolean;
|
|
22
|
+
cancelledMessage?: string | null;
|
|
23
|
+
target?: string;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runCancellableOperation(
|
|
28
|
+
ctx: ExtensionCommandContext,
|
|
29
|
+
message: string,
|
|
30
|
+
route: string,
|
|
31
|
+
runRoute: RunRoute,
|
|
32
|
+
options: CancellableOperationOptions = {},
|
|
33
|
+
): Promise<CancellableOperationResult> {
|
|
34
|
+
const {
|
|
35
|
+
commitAware = false,
|
|
36
|
+
cancelledMessage = "Check cancelled; no settings or files were changed.",
|
|
37
|
+
target,
|
|
38
|
+
signal,
|
|
39
|
+
} = options;
|
|
40
|
+
if (ctx.mode !== "tui") {
|
|
41
|
+
return await runRoute(route, undefined, undefined, target);
|
|
42
|
+
}
|
|
43
|
+
let commitStarted = false;
|
|
44
|
+
let routeResult: SetupPullOutcome | undefined;
|
|
45
|
+
const interaction = await runCustomInteraction<{ cancelled?: boolean; error?: unknown }>(ctx, {
|
|
46
|
+
signal,
|
|
47
|
+
isCurrent: () => !signal?.aborted,
|
|
48
|
+
create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
|
|
49
|
+
const loader = new BorderedLoader(tui, theme, message, { cancellable: false });
|
|
50
|
+
const cancelHint = `${keybindingText(keybindings, "tui.select.cancel", "esc")} cancel`;
|
|
51
|
+
const operation = runRoute(
|
|
52
|
+
route,
|
|
53
|
+
interactionSignal,
|
|
54
|
+
commitAware ? () => (commitStarted = true) : undefined,
|
|
55
|
+
target,
|
|
56
|
+
).then(
|
|
57
|
+
(result) => {
|
|
58
|
+
routeResult = result;
|
|
59
|
+
complete({});
|
|
60
|
+
},
|
|
61
|
+
(error: unknown) => complete({ error }),
|
|
62
|
+
);
|
|
63
|
+
return {
|
|
64
|
+
render(width: number) {
|
|
65
|
+
const safeWidth = Math.max(1, width);
|
|
66
|
+
const lines = loader.render(safeWidth);
|
|
67
|
+
const bottomBorder = lines.at(-1);
|
|
68
|
+
return [
|
|
69
|
+
...lines.slice(0, -1),
|
|
70
|
+
truncateToWidth(theme.fg("dim", cancelHint), safeWidth, ""),
|
|
71
|
+
...(bottomBorder === undefined ? [] : [bottomBorder]),
|
|
72
|
+
];
|
|
73
|
+
},
|
|
74
|
+
invalidate: () => loader.invalidate(),
|
|
75
|
+
handleInput(data: string) {
|
|
76
|
+
if (!keybindings.matches(data, "tui.select.cancel")) return;
|
|
77
|
+
if (commitStarted) {
|
|
78
|
+
ctx.ui.notify(
|
|
79
|
+
"Applying or publishing has started and cannot be cancelled safely.",
|
|
80
|
+
"warning",
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
complete({ cancelled: true });
|
|
85
|
+
},
|
|
86
|
+
dispose: () => loader.dispose(),
|
|
87
|
+
waitForPending: () => operation,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (interaction.kind === "error") throw interaction.error;
|
|
92
|
+
if (interaction.kind !== "completed") return "closed";
|
|
93
|
+
if (interaction.value.cancelled) {
|
|
94
|
+
if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
|
|
95
|
+
return "cancelled";
|
|
96
|
+
}
|
|
97
|
+
if (interaction.value.error) throw interaction.value.error;
|
|
98
|
+
return routeResult;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function keybindingText(
|
|
102
|
+
keybindings: Pick<KeybindingsManager, "getKeys">,
|
|
103
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
104
|
+
fallback: string,
|
|
105
|
+
) {
|
|
106
|
+
const keys = keybindings
|
|
107
|
+
.getKeys(binding)
|
|
108
|
+
.map(String)
|
|
109
|
+
.map((key) => {
|
|
110
|
+
if (key === "return") return "enter";
|
|
111
|
+
if (key === "escape") return "esc";
|
|
112
|
+
return safeTerminalText(key);
|
|
113
|
+
})
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
return keys.join("/") || fallback;
|
|
116
|
+
}
|
package/src/manager-ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import { type RunRoute, runCancellableOperation } from "./cancellable-operation.js";
|
|
3
4
|
import { setSyncSetupCompletions } from "./command.js";
|
|
4
5
|
import {
|
|
5
6
|
configuredSyncSetupNames,
|
|
@@ -29,20 +30,13 @@ import {
|
|
|
29
30
|
updateSyncSetup,
|
|
30
31
|
} from "./settings-management.js";
|
|
31
32
|
import { showSyncSettings } from "./settings-ui.js";
|
|
32
|
-
import {
|
|
33
|
+
import { useSyncSetup } from "./setup-switch.js";
|
|
33
34
|
import { showAddStorageConnection, showStorageConnections } from "./storage-connections-ui.js";
|
|
34
35
|
import { DEFAULT_SYNC_INCLUDE, syncIncludeSelection } from "./sync-policy.js";
|
|
35
36
|
import { showSyncSetups } from "./sync-setups-ui.js";
|
|
36
37
|
import type { AnySyncConfig } from "./types.js";
|
|
37
38
|
import { showAddWebDavTarget, showEditWebDavTarget, showWebDavSetup } from "./webdav-ui.js";
|
|
38
39
|
|
|
39
|
-
type RunRoute = (
|
|
40
|
-
route: string,
|
|
41
|
-
signal?: AbortSignal,
|
|
42
|
-
onCommit?: () => void,
|
|
43
|
-
target?: string,
|
|
44
|
-
) => Promise<SetupPullOutcome | undefined>;
|
|
45
|
-
|
|
46
40
|
export async function showSyncManager(
|
|
47
41
|
ctx: ExtensionCommandContext,
|
|
48
42
|
runRoute: RunRoute,
|
|
@@ -116,16 +110,33 @@ export async function showSyncManager(
|
|
|
116
110
|
},
|
|
117
111
|
actions: {
|
|
118
112
|
sync: async () => {
|
|
119
|
-
await runCancellableOperation(
|
|
120
|
-
|
|
113
|
+
const result = await runCancellableOperation(
|
|
114
|
+
ctx,
|
|
115
|
+
"Checking current sync setup…",
|
|
116
|
+
"sync",
|
|
117
|
+
runRoute,
|
|
118
|
+
{
|
|
119
|
+
commitAware: true,
|
|
120
|
+
signal: sessionSignal,
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
return result === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
121
124
|
},
|
|
122
125
|
switch: async () => {
|
|
123
126
|
const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
|
|
124
|
-
return result === "pull-attempted"
|
|
127
|
+
return result === "pull-attempted" || result === "closed"
|
|
128
|
+
? { kind: "close" }
|
|
129
|
+
: { kind: "stay" };
|
|
125
130
|
},
|
|
126
131
|
diff: async () => {
|
|
127
|
-
await runCancellableOperation(
|
|
128
|
-
|
|
132
|
+
const result = await runCancellableOperation(
|
|
133
|
+
ctx,
|
|
134
|
+
"Checking current sync setup…",
|
|
135
|
+
"diff",
|
|
136
|
+
runRoute,
|
|
137
|
+
{ signal: sessionSignal },
|
|
138
|
+
);
|
|
139
|
+
return result === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
129
140
|
},
|
|
130
141
|
settings: async () => {
|
|
131
142
|
await showSyncSettings(ctx, runRoute, sessionSignal);
|
|
@@ -137,21 +148,27 @@ export async function showSyncManager(
|
|
|
137
148
|
"Checking remote changes…",
|
|
138
149
|
"pull",
|
|
139
150
|
runRoute,
|
|
140
|
-
|
|
141
|
-
|
|
151
|
+
{
|
|
152
|
+
commitAware: true,
|
|
153
|
+
cancelledMessage: "Pull check cancelled; no local files were changed.",
|
|
154
|
+
signal: sessionSignal,
|
|
155
|
+
},
|
|
142
156
|
);
|
|
143
|
-
return result === "applied" ? { kind: "close" } : { kind: "stay" };
|
|
157
|
+
return result === "applied" || result === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
144
158
|
},
|
|
145
159
|
push: async () => {
|
|
146
|
-
await runCancellableOperation(
|
|
160
|
+
const result = await runCancellableOperation(
|
|
147
161
|
ctx,
|
|
148
162
|
"Preparing push preview…",
|
|
149
163
|
"push",
|
|
150
164
|
runRoute,
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
{
|
|
166
|
+
commitAware: true,
|
|
167
|
+
cancelledMessage: "Push preparation cancelled; no remote files were changed.",
|
|
168
|
+
signal: sessionSignal,
|
|
169
|
+
},
|
|
153
170
|
);
|
|
154
|
-
return { kind: "stay" };
|
|
171
|
+
return result === "closed" ? { kind: "close" } : { kind: "stay" };
|
|
155
172
|
},
|
|
156
173
|
setups: async () => {
|
|
157
174
|
const result = await showSyncSetupManager(ctx, runRoute, sessionSignal);
|
|
@@ -226,61 +243,6 @@ function syncMainMenuItem(
|
|
|
226
243
|
return { id: actions.get(label) ?? "help", label, action: actions.get(label) ?? "help" };
|
|
227
244
|
}
|
|
228
245
|
|
|
229
|
-
async function runCancellableOperation(
|
|
230
|
-
ctx: ExtensionCommandContext,
|
|
231
|
-
message: string,
|
|
232
|
-
route: string,
|
|
233
|
-
runRoute: RunRoute,
|
|
234
|
-
commitAware = false,
|
|
235
|
-
cancelledMessage: string | null = "Check cancelled; no settings or files were changed.",
|
|
236
|
-
target?: string,
|
|
237
|
-
) {
|
|
238
|
-
if (ctx.mode !== "tui") {
|
|
239
|
-
return await runRoute(route, undefined, undefined, target);
|
|
240
|
-
}
|
|
241
|
-
let commitStarted = false;
|
|
242
|
-
let operation: Promise<void> | undefined;
|
|
243
|
-
let routeResult: SetupPullOutcome | undefined;
|
|
244
|
-
const result = await ctx.ui.custom<{ cancelled?: boolean; error?: unknown }>(
|
|
245
|
-
(tui, theme, _kb, done) => {
|
|
246
|
-
const loader = new BorderedLoader(tui, theme, message, { cancellable: true });
|
|
247
|
-
let closed = false;
|
|
248
|
-
loader.onAbort = () => {
|
|
249
|
-
if (commitStarted) {
|
|
250
|
-
ctx.ui.notify(
|
|
251
|
-
"Applying or publishing has started and cannot be cancelled safely.",
|
|
252
|
-
"warning",
|
|
253
|
-
);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
closed = true;
|
|
257
|
-
done({ cancelled: true });
|
|
258
|
-
};
|
|
259
|
-
operation = runRoute(
|
|
260
|
-
route,
|
|
261
|
-
loader.signal,
|
|
262
|
-
commitAware ? () => (commitStarted = true) : undefined,
|
|
263
|
-
target,
|
|
264
|
-
)
|
|
265
|
-
.then((result) => {
|
|
266
|
-
routeResult = result;
|
|
267
|
-
if (!closed) done({});
|
|
268
|
-
})
|
|
269
|
-
.catch((error) => {
|
|
270
|
-
if (!closed) done({ error });
|
|
271
|
-
});
|
|
272
|
-
return loader;
|
|
273
|
-
},
|
|
274
|
-
);
|
|
275
|
-
if (result?.cancelled) {
|
|
276
|
-
await operation;
|
|
277
|
-
if (cancelledMessage) ctx.ui.notify(cancelledMessage, "info");
|
|
278
|
-
return "cancelled";
|
|
279
|
-
}
|
|
280
|
-
if (result?.error) throw result.error;
|
|
281
|
-
return routeResult;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
246
|
export async function showSetupWizard(ctx: ExtensionCommandContext, signal?: AbortSignal) {
|
|
285
247
|
if (ctx.mode !== "tui") {
|
|
286
248
|
ctx.ui.notify(
|
|
@@ -543,23 +505,34 @@ async function showSetupSwitcher(
|
|
|
543
505
|
);
|
|
544
506
|
if (signal?.aborted || !confirmed) return false;
|
|
545
507
|
try {
|
|
508
|
+
let pullClosed = false;
|
|
546
509
|
const result = await useSyncSetup(
|
|
547
510
|
ctx,
|
|
548
511
|
name,
|
|
549
|
-
(selectedTarget) =>
|
|
550
|
-
runCancellableOperation(
|
|
512
|
+
async (selectedTarget) => {
|
|
513
|
+
const pullResult = await runCancellableOperation(
|
|
551
514
|
ctx,
|
|
552
515
|
`Pulling sync setup “${safeTerminalText(name)}”…`,
|
|
553
516
|
"pull",
|
|
554
517
|
runRoute,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
518
|
+
{
|
|
519
|
+
commitAware: true,
|
|
520
|
+
cancelledMessage: null,
|
|
521
|
+
target: selectedTarget,
|
|
522
|
+
signal,
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
if (pullResult === "closed") {
|
|
526
|
+
pullClosed = true;
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
return pullResult;
|
|
530
|
+
},
|
|
559
531
|
onSwitch,
|
|
560
532
|
signal,
|
|
561
533
|
syncConfigReviewIdentity(config),
|
|
562
534
|
);
|
|
535
|
+
if (pullClosed) return "closed";
|
|
563
536
|
return result.pullApplied ? "pull-attempted" : "switched";
|
|
564
537
|
} catch (error) {
|
|
565
538
|
if (signal?.aborted) return false;
|
|
@@ -587,7 +560,7 @@ async function showSyncSetupManager(
|
|
|
587
560
|
},
|
|
588
561
|
makeCurrent: async (name, setupSignal) => {
|
|
589
562
|
const result = await showSetupSwitcher(ctx, runRoute, name, setupSignal);
|
|
590
|
-
return result === "pull-attempted" ? "exit" : undefined;
|
|
563
|
+
return result === "pull-attempted" || result === "closed" ? "exit" : undefined;
|
|
591
564
|
},
|
|
592
565
|
remove: async (name, setupSignal) => {
|
|
593
566
|
await showRemoveTarget(ctx, name, setupSignal);
|
package/src/secret-input.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Text,
|
|
8
8
|
truncateToWidth,
|
|
9
9
|
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
10
11
|
|
|
11
12
|
const MASK = "•";
|
|
12
13
|
|
|
@@ -16,63 +17,62 @@ export async function promptSecret(
|
|
|
16
17
|
options: { required?: boolean; signal?: AbortSignal } = { required: true },
|
|
17
18
|
) {
|
|
18
19
|
if (ctx.mode !== "tui" || options.signal?.aborted) return undefined;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
heading
|
|
24
|
-
hint
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
return component;
|
|
20
|
+
const result = await runCustomInteraction<string | undefined>(ctx, {
|
|
21
|
+
signal: options.signal,
|
|
22
|
+
isCurrent: () => !options.signal?.aborted,
|
|
23
|
+
create: ({ tui, theme, keybindings, complete }) => {
|
|
24
|
+
const heading = new Text("", 0, 0);
|
|
25
|
+
const hint = new Text("", 0, 0);
|
|
26
|
+
const submitKey = keybindingText(keybindings, "tui.input.submit", "enter");
|
|
27
|
+
const cancelKey = keybindingText(keybindings, "tui.select.cancel", "esc");
|
|
28
|
+
const applyTheme = () => {
|
|
29
|
+
heading.setText(theme.fg("accent", theme.bold(title)));
|
|
30
|
+
hint.setText(theme.fg("dim", `${submitKey} save • ${cancelKey} cancel • Input is hidden`));
|
|
31
|
+
};
|
|
32
|
+
applyTheme();
|
|
33
|
+
const input = new MaskedInput(keybindings);
|
|
34
|
+
const component: Focusable & {
|
|
35
|
+
render(width: number): string[];
|
|
36
|
+
invalidate(): void;
|
|
37
|
+
handleInput(data: string): void;
|
|
38
|
+
dispose(): void;
|
|
39
|
+
} = {
|
|
40
|
+
get focused() {
|
|
41
|
+
return input.focused;
|
|
42
|
+
},
|
|
43
|
+
set focused(focused: boolean) {
|
|
44
|
+
input.focused = focused;
|
|
45
|
+
},
|
|
46
|
+
render(width: number) {
|
|
47
|
+
const safeWidth = Math.max(1, width);
|
|
48
|
+
return [
|
|
49
|
+
...heading.render(safeWidth),
|
|
50
|
+
...input.render(safeWidth),
|
|
51
|
+
...hint.render(safeWidth),
|
|
52
|
+
].map((line) => truncateToWidth(line, safeWidth));
|
|
53
|
+
},
|
|
54
|
+
invalidate() {
|
|
55
|
+
applyTheme();
|
|
56
|
+
heading.invalidate();
|
|
57
|
+
input.invalidate();
|
|
58
|
+
hint.invalidate();
|
|
59
|
+
},
|
|
60
|
+
handleInput(data: string) {
|
|
61
|
+
if (keybindings.matches(data, "tui.select.cancel")) complete(undefined);
|
|
62
|
+
else if (keybindings.matches(data, "tui.input.submit")) complete(input.getValue());
|
|
63
|
+
else input.handleInput(data);
|
|
64
|
+
tui.requestRender();
|
|
65
|
+
},
|
|
66
|
+
dispose() {
|
|
67
|
+
input.clear();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return component;
|
|
71
|
+
},
|
|
74
72
|
});
|
|
75
|
-
if (
|
|
73
|
+
if (result.kind === "error") throw result.error;
|
|
74
|
+
if (result.kind !== "completed" || result.value === undefined) return undefined;
|
|
75
|
+
const value = result.value;
|
|
76
76
|
if (options.required !== false && value.length === 0) {
|
|
77
77
|
ctx.ui.notify(`${title} is required.`, "warning");
|
|
78
78
|
return undefined;
|
|
@@ -191,6 +191,23 @@ class MaskedInput implements Focusable {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
function keybindingText(
|
|
195
|
+
keybindings: Pick<KeybindingsManager, "getKeys">,
|
|
196
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
197
|
+
fallback: string,
|
|
198
|
+
) {
|
|
199
|
+
const keys = keybindings
|
|
200
|
+
.getKeys(binding)
|
|
201
|
+
.map(String)
|
|
202
|
+
.map((key) => {
|
|
203
|
+
if (key === "return") return "enter";
|
|
204
|
+
if (key === "escape") return "esc";
|
|
205
|
+
return hasControlCharacter(key) ? "" : key;
|
|
206
|
+
})
|
|
207
|
+
.filter(Boolean);
|
|
208
|
+
return keys.join("/") || fallback;
|
|
209
|
+
}
|
|
210
|
+
|
|
194
211
|
function hasControlCharacter(value: string) {
|
|
195
212
|
return [...value].some((character) => {
|
|
196
213
|
const code = character.codePointAt(0) ?? 0;
|