@arthony/keybook 0.2.0 → 0.3.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/README.md +5 -0
- package/dist/cli.js +1113 -400
- package/package.json +3 -3
- package/seed/nano.yaml +53 -12
package/dist/cli.js
CHANGED
|
@@ -1,436 +1,1149 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { dirname as dirname2, join as join3 } from "path";
|
|
7
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
8
6
|
import { Command } from "commander";
|
|
9
|
-
import { render } from "ink";
|
|
10
|
-
import { createElement } from "react";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
import { Box, Text, render, useApp, useInput, useStdout } from "ink";
|
|
8
|
+
import { createElement, useCallback, useMemo, useState } from "react";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { Document, parse, parseDocument } from "yaml";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { Fzf, extendedMatch } from "fzf";
|
|
14
|
+
//#region src/config.ts
|
|
15
|
+
const YAML_RE$2 = /\.ya?ml$/;
|
|
18
16
|
function resolveDataDir(env = process.env) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
if (env.KEYBOOK_DATA_DIR) return {
|
|
18
|
+
dir: env.KEYBOOK_DATA_DIR,
|
|
19
|
+
source: "env"
|
|
20
|
+
};
|
|
21
|
+
if (env.XDG_CONFIG_HOME) return {
|
|
22
|
+
dir: join(env.XDG_CONFIG_HOME, "keybook"),
|
|
23
|
+
source: "xdg"
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
dir: join(homedir(), ".config", "keybook"),
|
|
27
|
+
source: "default"
|
|
28
|
+
};
|
|
22
29
|
}
|
|
30
|
+
/** Locate the bundled seed/ relative to this module (works in dist and in tests). */
|
|
23
31
|
function seedDir() {
|
|
24
|
-
|
|
32
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "seed");
|
|
25
33
|
}
|
|
26
34
|
function ensureDataDir(dir, seed = seedDir()) {
|
|
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
|
-
|
|
35
|
+
if (existsSync(dir) && readdirSync(dir).some((f) => YAML_RE$2.test(f))) return {
|
|
36
|
+
initialized: false,
|
|
37
|
+
fileCount: 0
|
|
38
|
+
};
|
|
39
|
+
mkdirSync(dir, { recursive: true });
|
|
40
|
+
const seedFiles = readdirSync(seed).filter((f) => YAML_RE$2.test(f));
|
|
41
|
+
for (const f of seedFiles) cpSync(join(seed, f), join(dir, f));
|
|
42
|
+
return {
|
|
43
|
+
initialized: true,
|
|
44
|
+
fileCount: seedFiles.length
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/data/keys.ts
|
|
49
|
+
const MODS = {
|
|
50
|
+
cmd: "⌘",
|
|
51
|
+
command: "⌘",
|
|
52
|
+
"⌘": "⌘",
|
|
53
|
+
opt: "⌥",
|
|
54
|
+
option: "⌥",
|
|
55
|
+
alt: "⌥",
|
|
56
|
+
"⌥": "⌥",
|
|
57
|
+
ctrl: "⌃",
|
|
58
|
+
control: "⌃",
|
|
59
|
+
"⌃": "⌃",
|
|
60
|
+
shift: "⇧",
|
|
61
|
+
"⇧": "⇧"
|
|
62
|
+
};
|
|
63
|
+
const NAMED = {
|
|
64
|
+
return: "⏎",
|
|
65
|
+
enter: "⏎",
|
|
66
|
+
"⏎": "⏎",
|
|
67
|
+
esc: "⎋",
|
|
68
|
+
escape: "⎋",
|
|
69
|
+
"⎋": "⎋",
|
|
70
|
+
del: "⌫",
|
|
71
|
+
delete: "⌫",
|
|
72
|
+
backspace: "⌫",
|
|
73
|
+
"⌫": "⌫",
|
|
74
|
+
tab: "⇥",
|
|
75
|
+
"⇥": "⇥",
|
|
76
|
+
space: "␣",
|
|
77
|
+
"␣": "␣",
|
|
78
|
+
up: "↑",
|
|
79
|
+
down: "↓",
|
|
80
|
+
left: "←",
|
|
81
|
+
right: "→",
|
|
82
|
+
"↑": "↑",
|
|
83
|
+
"↓": "↓",
|
|
84
|
+
"←": "←",
|
|
85
|
+
"→": "→"
|
|
86
|
+
};
|
|
87
|
+
const MOD_ORDER = [
|
|
88
|
+
"⌃",
|
|
89
|
+
"⌥",
|
|
90
|
+
"⇧",
|
|
91
|
+
"⌘"
|
|
92
|
+
];
|
|
93
|
+
function normalizeSegment(seg) {
|
|
94
|
+
const trimmed = seg.trim();
|
|
95
|
+
if (!trimmed) return "";
|
|
96
|
+
const tokens = trimmed.split(/[\s+]+/).filter(Boolean);
|
|
97
|
+
const mods = /* @__PURE__ */ new Set();
|
|
98
|
+
const keys = [];
|
|
99
|
+
for (const tok of tokens) {
|
|
100
|
+
const low = tok.toLowerCase();
|
|
101
|
+
if (MODS[low]) mods.add(MODS[low]);
|
|
102
|
+
else if (NAMED[low]) keys.push(NAMED[low]);
|
|
103
|
+
else keys.push(tok.length === 1 && /[a-z]/i.test(tok) ? tok.toUpperCase() : tok);
|
|
104
|
+
}
|
|
105
|
+
return MOD_ORDER.filter((m) => mods.has(m)).join("") + keys.join("");
|
|
106
|
+
}
|
|
107
|
+
/** Normalize human key input into the canonical macOS glyph string. Never throws. */
|
|
108
|
+
function normalizeKeys(input) {
|
|
109
|
+
return input.split(/\s*,\s*|\s+then\s+/i).map(normalizeSegment).filter(Boolean).join(", ");
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/data/schema.ts
|
|
113
|
+
const entrySchema = z.object({
|
|
114
|
+
action: z.string().min(1),
|
|
115
|
+
keys: z.string().min(1).optional(),
|
|
116
|
+
steps: z.array(z.string().min(1)).min(1).optional(),
|
|
117
|
+
command: z.string().min(1).optional(),
|
|
118
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
119
|
+
notes: z.string().min(1).optional(),
|
|
120
|
+
source: z.string().min(1).optional()
|
|
121
|
+
}).strict().refine((e) => Boolean(e.keys) || Boolean(e.steps) || Boolean(e.command), { message: "entry must have at least one of: keys, steps, command" });
|
|
122
|
+
const fileShape = z.object({
|
|
123
|
+
app: z.string().min(1),
|
|
124
|
+
entries: z.array(z.unknown()).min(1)
|
|
56
125
|
}).strict();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/data/loader.ts
|
|
128
|
+
const YAML_RE$1 = /\.ya?ml$/;
|
|
60
129
|
function loadEntries(dataDir) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
const entries = [];
|
|
131
|
+
const errors = [];
|
|
132
|
+
let files;
|
|
133
|
+
try {
|
|
134
|
+
files = readdirSync(dataDir).filter((f) => YAML_RE$1.test(f)).sort();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
errors.push({
|
|
137
|
+
file: dataDir,
|
|
138
|
+
entryIndex: null,
|
|
139
|
+
message: `cannot read data dir: ${err.message}`
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
entries,
|
|
143
|
+
errors
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
raw = parse(readFileSync(join(dataDir, file), "utf8"));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
errors.push({
|
|
152
|
+
file,
|
|
153
|
+
entryIndex: null,
|
|
154
|
+
message: `YAML parse error: ${err.message}`
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const shape = fileShape.safeParse(raw);
|
|
159
|
+
if (!shape.success) {
|
|
160
|
+
errors.push({
|
|
161
|
+
file,
|
|
162
|
+
entryIndex: null,
|
|
163
|
+
message: shape.error.issues[0]?.message ?? "invalid file shape"
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const { app, entries: rawEntries } = shape.data;
|
|
168
|
+
rawEntries.forEach((rawEntry, i) => {
|
|
169
|
+
const parsed = entrySchema.safeParse(rawEntry);
|
|
170
|
+
if (!parsed.success) {
|
|
171
|
+
errors.push({
|
|
172
|
+
file,
|
|
173
|
+
entryIndex: i,
|
|
174
|
+
message: parsed.error.issues.map((s) => s.message).join("; ")
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
entries.push({
|
|
179
|
+
app,
|
|
180
|
+
...parsed.data
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
entries,
|
|
186
|
+
errors
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/data/writer.ts
|
|
191
|
+
const YAML_RE = /\.ya?ml$/;
|
|
192
|
+
function yamlFiles(dir) {
|
|
193
|
+
if (!existsSync(dir)) return [];
|
|
194
|
+
return readdirSync(dir).filter((f) => YAML_RE.test(f)).sort();
|
|
195
|
+
}
|
|
196
|
+
function readApp(path) {
|
|
197
|
+
try {
|
|
198
|
+
const app = parseDocument(readFileSync(path, "utf8")).get("app");
|
|
199
|
+
return typeof app === "string" && app.trim() ? app.trim() : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function listApps(dir) {
|
|
205
|
+
const seen = /* @__PURE__ */ new Map();
|
|
206
|
+
for (const f of yamlFiles(dir)) {
|
|
207
|
+
const app = readApp(join(dir, f));
|
|
208
|
+
if (app && !seen.has(app.toLowerCase())) seen.set(app.toLowerCase(), app);
|
|
209
|
+
}
|
|
210
|
+
return [...seen.values()].sort((a, b) => a.localeCompare(b));
|
|
211
|
+
}
|
|
212
|
+
function slugify(app) {
|
|
213
|
+
return app.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
214
|
+
}
|
|
215
|
+
function findFileForApp(dir, app) {
|
|
216
|
+
const want = app.trim().toLowerCase();
|
|
217
|
+
for (const f of yamlFiles(dir)) if (readApp(join(dir, f))?.toLowerCase() === want) return join(dir, f);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
function freshPath(dir, app) {
|
|
221
|
+
const base = slugify(app);
|
|
222
|
+
let name = `${base}.yaml`;
|
|
223
|
+
let n = 2;
|
|
224
|
+
while (existsSync(join(dir, name))) {
|
|
225
|
+
name = `${base}-${n}.yaml`;
|
|
226
|
+
n += 1;
|
|
227
|
+
}
|
|
228
|
+
return join(dir, name);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Resolve where an entry for `app` would be written, without writing anything.
|
|
232
|
+
* Returns the existing file (created:false) or the fresh slug path (created:true).
|
|
233
|
+
*/
|
|
234
|
+
function resolveTargetFile(dir, app) {
|
|
235
|
+
const existing = findFileForApp(dir, app);
|
|
236
|
+
if (existing) return {
|
|
237
|
+
file: existing,
|
|
238
|
+
created: false
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
file: freshPath(dir, app),
|
|
242
|
+
created: true
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function buildClean(e) {
|
|
246
|
+
const out = { action: e.action };
|
|
247
|
+
if (e.keys) out.keys = e.keys;
|
|
248
|
+
if (e.steps?.length) out.steps = e.steps;
|
|
249
|
+
if (e.command) out.command = e.command;
|
|
250
|
+
if (e.tags?.length) out.tags = e.tags;
|
|
251
|
+
if (e.notes) out.notes = e.notes;
|
|
252
|
+
if (e.source) out.source = e.source;
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
function err(file, lines, created = false) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
file,
|
|
259
|
+
created,
|
|
260
|
+
lines
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function addEntry(dir, app, entry) {
|
|
264
|
+
if ("app" in entry) return err("", ["Error: entry must not have an app field; use --app instead"]);
|
|
265
|
+
if (!app.trim()) return err("", ["Error: app is required"]);
|
|
266
|
+
const parsed = entrySchema.safeParse(entry);
|
|
267
|
+
if (!parsed.success) return err("", parsed.error.issues.map((i) => i.message));
|
|
268
|
+
const clean = buildClean(parsed.data);
|
|
269
|
+
try {
|
|
270
|
+
mkdirSync(dir, { recursive: true });
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return err(dir, [e.message]);
|
|
273
|
+
}
|
|
274
|
+
const existing = findFileForApp(dir, app);
|
|
275
|
+
const created = !existing;
|
|
276
|
+
const file = existing ?? freshPath(dir, app);
|
|
277
|
+
let original = null;
|
|
278
|
+
let text;
|
|
279
|
+
if (created) text = new Document({
|
|
280
|
+
app: app.trim(),
|
|
281
|
+
entries: [clean]
|
|
282
|
+
}).toString();
|
|
283
|
+
else {
|
|
284
|
+
try {
|
|
285
|
+
original = readFileSync(file, "utf8");
|
|
286
|
+
} catch (e) {
|
|
287
|
+
return err(file, [e.message]);
|
|
288
|
+
}
|
|
289
|
+
const doc = parseDocument(original);
|
|
290
|
+
doc.addIn(["entries"], doc.createNode(clean));
|
|
291
|
+
text = doc.toString();
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
writeFileSync(file, text);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return err(file, [e.message], created);
|
|
297
|
+
}
|
|
298
|
+
const fileErr = loadEntries(dir).errors.find((e) => e.file === basename(file));
|
|
299
|
+
if (fileErr) {
|
|
300
|
+
if (created) try {
|
|
301
|
+
unlinkSync(file);
|
|
302
|
+
} catch {}
|
|
303
|
+
else if (original !== null) writeFileSync(file, original);
|
|
304
|
+
return err(file, [`✗ ${fileErr.message}`], created);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
file,
|
|
309
|
+
created,
|
|
310
|
+
lines: [`✓ ${created ? "created" : "added to"} ${basename(file)}`]
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/commands.ts
|
|
113
315
|
function runPath(env = process.env) {
|
|
114
|
-
|
|
115
|
-
|
|
316
|
+
const { dir, source } = resolveDataDir(env);
|
|
317
|
+
return `${dir} (source: ${source})`;
|
|
116
318
|
}
|
|
117
319
|
function runCheck(dir) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// src/tui/App.tsx
|
|
129
|
-
import { Box as Box6, useApp, useInput, useStdout } from "ink";
|
|
130
|
-
import { useMemo, useState } from "react";
|
|
131
|
-
|
|
132
|
-
// src/clipboard.ts
|
|
133
|
-
import { spawnSync } from "child_process";
|
|
134
|
-
function copyToClipboard(text) {
|
|
135
|
-
try {
|
|
136
|
-
const res = spawnSync("pbcopy", { input: text });
|
|
137
|
-
return res.status === 0;
|
|
138
|
-
} catch {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// src/search.ts
|
|
144
|
-
import { Fzf, extendedMatch } from "fzf";
|
|
145
|
-
function haystack(e) {
|
|
146
|
-
return [e.action, (e.tags ?? []).join(" "), e.app, e.keys ?? "", e.notes ?? ""].filter(Boolean).join(" ");
|
|
320
|
+
const { entries, errors } = loadEntries(dir);
|
|
321
|
+
if (errors.length === 0) return {
|
|
322
|
+
ok: true,
|
|
323
|
+
lines: [`✓ ${entries.length} entries OK`]
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
lines: errors.map((e) => `✗ ${e.file}${e.entryIndex !== null ? ` [entry ${e.entryIndex}]` : ""}: ${e.message}`)
|
|
328
|
+
};
|
|
147
329
|
}
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
330
|
+
function runAdd(dir, draft) {
|
|
331
|
+
const entry = { action: draft.action };
|
|
332
|
+
const keys = draft.keys ? normalizeKeys(draft.keys).trim() : "";
|
|
333
|
+
if (keys) entry.keys = keys;
|
|
334
|
+
if (draft.command?.trim()) entry.command = draft.command.trim();
|
|
335
|
+
if (draft.steps?.length) entry.steps = draft.steps;
|
|
336
|
+
if (draft.tags?.length) entry.tags = draft.tags;
|
|
337
|
+
if (draft.notes?.trim()) entry.notes = draft.notes.trim();
|
|
338
|
+
const result = addEntry(dir, draft.app, entry);
|
|
339
|
+
return {
|
|
340
|
+
ok: result.ok,
|
|
341
|
+
lines: result.lines
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/tui/StepsBuilder.tsx
|
|
346
|
+
function StepsBuilder({ steps, line, active }) {
|
|
347
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
348
|
+
flexDirection: "column",
|
|
349
|
+
children: [
|
|
350
|
+
steps.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
351
|
+
i + 1,
|
|
352
|
+
". ",
|
|
353
|
+
s
|
|
354
|
+
] }, i)),
|
|
355
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
356
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
357
|
+
color: active ? "cyan" : "gray",
|
|
358
|
+
children: [steps.length + 1, ". "]
|
|
359
|
+
}),
|
|
360
|
+
/* @__PURE__ */ jsx(Text, { children: line }),
|
|
361
|
+
active ? /* @__PURE__ */ jsx(Text, {
|
|
362
|
+
inverse: true,
|
|
363
|
+
children: " "
|
|
364
|
+
}) : null
|
|
365
|
+
] }),
|
|
366
|
+
steps.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
367
|
+
color: "gray",
|
|
368
|
+
children: "⏎ adds a step · ⌫ on an empty line removes the last"
|
|
369
|
+
}) : null
|
|
370
|
+
]
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/tui/FormFields.tsx
|
|
375
|
+
const TYPES$1 = [
|
|
376
|
+
"shortcut",
|
|
377
|
+
"command",
|
|
378
|
+
"recipe"
|
|
379
|
+
];
|
|
380
|
+
function Field({ label, value, focused }) {
|
|
381
|
+
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
382
|
+
/* @__PURE__ */ jsx(Text, {
|
|
383
|
+
color: focused ? "cyan" : "gray",
|
|
384
|
+
children: label.padEnd(8)
|
|
385
|
+
}),
|
|
386
|
+
/* @__PURE__ */ jsx(Text, { children: value }),
|
|
387
|
+
focused ? /* @__PURE__ */ jsx(Text, {
|
|
388
|
+
inverse: true,
|
|
389
|
+
children: " "
|
|
390
|
+
}) : null
|
|
391
|
+
] });
|
|
392
|
+
}
|
|
393
|
+
function FormFields({ draft, apps, appIndex, focused, existingTags }) {
|
|
394
|
+
const appChoices = [...apps, "Create new app…"];
|
|
395
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
396
|
+
flexDirection: "column",
|
|
397
|
+
children: [
|
|
398
|
+
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
399
|
+
color: focused === 0 ? "cyan" : "gray",
|
|
400
|
+
children: "App".padEnd(8)
|
|
401
|
+
}), draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
402
|
+
inverse: true,
|
|
403
|
+
children: " "
|
|
404
|
+
}) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
|
|
405
|
+
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
406
|
+
color: focused === 1 ? "cyan" : "gray",
|
|
407
|
+
children: "Type".padEnd(8)
|
|
408
|
+
}), /* @__PURE__ */ jsx(Text, { children: TYPES$1.map((t) => t === draft.type ? `(•) ${t}` : `( ) ${t}`).join(" ") })] }),
|
|
409
|
+
/* @__PURE__ */ jsx(Field, {
|
|
410
|
+
label: "Action",
|
|
411
|
+
value: draft.action,
|
|
412
|
+
focused: focused === 2
|
|
413
|
+
}),
|
|
414
|
+
draft.type === "shortcut" ? /* @__PURE__ */ jsxs(Box, { children: [
|
|
415
|
+
/* @__PURE__ */ jsx(Text, {
|
|
416
|
+
color: focused === 3 ? "cyan" : "gray",
|
|
417
|
+
children: "Keys".padEnd(8)
|
|
418
|
+
}),
|
|
419
|
+
/* @__PURE__ */ jsx(Text, { children: draft.keys }),
|
|
420
|
+
focused === 3 ? /* @__PURE__ */ jsx(Text, {
|
|
421
|
+
inverse: true,
|
|
422
|
+
children: " "
|
|
423
|
+
}) : null,
|
|
424
|
+
draft.keys.trim() ? /* @__PURE__ */ jsxs(Text, {
|
|
425
|
+
color: "gray",
|
|
426
|
+
children: [" → ", normalizeKeys(draft.keys)]
|
|
427
|
+
}) : null
|
|
428
|
+
] }) : draft.type === "command" ? /* @__PURE__ */ jsx(Field, {
|
|
429
|
+
label: "Command",
|
|
430
|
+
value: `$ ${draft.command}`,
|
|
431
|
+
focused: focused === 3
|
|
432
|
+
}) : /* @__PURE__ */ jsxs(Box, {
|
|
433
|
+
flexDirection: "column",
|
|
434
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
435
|
+
color: focused === 3 ? "cyan" : "gray",
|
|
436
|
+
children: "Steps"
|
|
437
|
+
}), /* @__PURE__ */ jsx(StepsBuilder, {
|
|
438
|
+
steps: draft.steps,
|
|
439
|
+
line: draft.stepLine,
|
|
440
|
+
active: focused === 3
|
|
441
|
+
})]
|
|
442
|
+
}),
|
|
443
|
+
/* @__PURE__ */ jsx(Field, {
|
|
444
|
+
label: "Tags",
|
|
445
|
+
value: draft.tags,
|
|
446
|
+
focused: focused === 4
|
|
447
|
+
}),
|
|
448
|
+
focused === 4 && existingTags && existingTags.length > 0 ? /* @__PURE__ */ jsx(Text, {
|
|
449
|
+
color: "gray",
|
|
450
|
+
children: `${"".padEnd(8)}e.g. ${existingTags.slice(0, 6).join(", ")}`
|
|
451
|
+
}) : null,
|
|
452
|
+
/* @__PURE__ */ jsx(Field, {
|
|
453
|
+
label: "Notes",
|
|
454
|
+
value: draft.notes,
|
|
455
|
+
focused: focused === 5
|
|
456
|
+
})
|
|
457
|
+
]
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/tui/keycaps.ts
|
|
462
|
+
const GLYPHS = new Set([
|
|
463
|
+
"⌘",
|
|
464
|
+
"⌥",
|
|
465
|
+
"⌃",
|
|
466
|
+
"⇧",
|
|
467
|
+
"⇪",
|
|
468
|
+
"⏎",
|
|
469
|
+
"⎋",
|
|
470
|
+
"⌫",
|
|
471
|
+
"⇥",
|
|
472
|
+
"␣",
|
|
473
|
+
"↑",
|
|
474
|
+
"↓",
|
|
475
|
+
"←",
|
|
476
|
+
"→"
|
|
477
|
+
]);
|
|
186
478
|
function parseSegment(seg) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (buf) tokens.push(buf);
|
|
206
|
-
return tokens.length ? tokens : [seg];
|
|
479
|
+
const tokens = [];
|
|
480
|
+
let buf = "";
|
|
481
|
+
for (const ch of seg) if (GLYPHS.has(ch)) {
|
|
482
|
+
if (buf) {
|
|
483
|
+
tokens.push(buf);
|
|
484
|
+
buf = "";
|
|
485
|
+
}
|
|
486
|
+
tokens.push(ch);
|
|
487
|
+
} else if (ch === " ") {
|
|
488
|
+
if (buf) {
|
|
489
|
+
tokens.push(buf);
|
|
490
|
+
buf = "";
|
|
491
|
+
}
|
|
492
|
+
} else buf += ch;
|
|
493
|
+
if (buf) tokens.push(buf);
|
|
494
|
+
return tokens.length ? tokens : [seg];
|
|
207
495
|
}
|
|
496
|
+
/** Parse a keys string into a chord sequence of token groups. Never throws. */
|
|
208
497
|
function parseKeys(input) {
|
|
209
|
-
|
|
498
|
+
return input.split(",").map((s) => s.trim()).filter(Boolean).map(parseSegment);
|
|
210
499
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/tui/key-caps.tsx
|
|
214
502
|
function KeyCaps({ value }) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
503
|
+
return /* @__PURE__ */ jsx(Box, { children: parseKeys(value).map((seg, si) => /* @__PURE__ */ jsxs(Box, { children: [si > 0 ? /* @__PURE__ */ jsx(Text, { children: " , " }) : null, seg.map((tok, ti) => /* @__PURE__ */ jsx(Box, {
|
|
504
|
+
marginRight: 1,
|
|
505
|
+
borderStyle: "round",
|
|
506
|
+
paddingX: 1,
|
|
507
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
508
|
+
bold: true,
|
|
509
|
+
children: tok
|
|
510
|
+
})
|
|
511
|
+
}, ti))] }, si)) });
|
|
512
|
+
}
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/tui/ReviewScreen.tsx
|
|
515
|
+
function ReviewScreen({ app, entry, targetPath, error }) {
|
|
516
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
517
|
+
flexDirection: "column",
|
|
518
|
+
children: [
|
|
519
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
520
|
+
color: "cyan",
|
|
521
|
+
children: ["Review — ", app]
|
|
522
|
+
}),
|
|
523
|
+
/* @__PURE__ */ jsx(Text, {
|
|
524
|
+
bold: true,
|
|
525
|
+
children: entry.action
|
|
526
|
+
}),
|
|
527
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
528
|
+
marginTop: 1,
|
|
529
|
+
flexDirection: "column",
|
|
530
|
+
children: [
|
|
531
|
+
entry.keys ? /* @__PURE__ */ jsx(KeyCaps, { value: entry.keys }) : null,
|
|
532
|
+
entry.steps?.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
533
|
+
i + 1,
|
|
534
|
+
". ",
|
|
535
|
+
s
|
|
536
|
+
] }, i)),
|
|
537
|
+
entry.command ? /* @__PURE__ */ jsxs(Text, {
|
|
538
|
+
color: "green",
|
|
539
|
+
children: ["$ ", entry.command]
|
|
540
|
+
}) : null
|
|
541
|
+
]
|
|
542
|
+
}),
|
|
543
|
+
entry.tags?.length ? /* @__PURE__ */ jsxs(Text, {
|
|
544
|
+
color: "gray",
|
|
545
|
+
children: ["tags: ", entry.tags.join(", ")]
|
|
546
|
+
}) : null,
|
|
547
|
+
entry.notes ? /* @__PURE__ */ jsx(Text, {
|
|
548
|
+
color: "gray",
|
|
549
|
+
children: entry.notes
|
|
550
|
+
}) : null,
|
|
551
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
552
|
+
color: "gray",
|
|
553
|
+
children: ["→ ", targetPath]
|
|
554
|
+
}),
|
|
555
|
+
error ? /* @__PURE__ */ jsx(Text, {
|
|
556
|
+
color: "red",
|
|
557
|
+
children: error
|
|
558
|
+
}) : null,
|
|
559
|
+
/* @__PURE__ */ jsx(Box, {
|
|
560
|
+
marginTop: 1,
|
|
561
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
562
|
+
color: "gray",
|
|
563
|
+
children: "⏎ save · e edit · esc cancel"
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
]
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/tui/useAddForm.ts
|
|
571
|
+
const emptyDraft = {
|
|
572
|
+
app: "",
|
|
573
|
+
newApp: "",
|
|
574
|
+
creatingApp: false,
|
|
575
|
+
type: "shortcut",
|
|
576
|
+
action: "",
|
|
577
|
+
keys: "",
|
|
578
|
+
command: "",
|
|
579
|
+
steps: [],
|
|
580
|
+
stepLine: "",
|
|
581
|
+
tags: "",
|
|
582
|
+
notes: ""
|
|
583
|
+
};
|
|
584
|
+
function parseTags(raw) {
|
|
585
|
+
return raw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
586
|
+
}
|
|
587
|
+
function resolvedApp(d) {
|
|
588
|
+
return (d.creatingApp ? d.newApp : d.app).trim();
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Flush a typed-but-not-yet-appended recipe step (`stepLine`) into `steps`.
|
|
592
|
+
* Returns `d` unchanged when `stepLine` is empty after trimming.
|
|
593
|
+
*/
|
|
594
|
+
function flushStep(d) {
|
|
595
|
+
const line = d.stepLine.trim();
|
|
596
|
+
if (!line) return d;
|
|
597
|
+
return {
|
|
598
|
+
...d,
|
|
599
|
+
steps: [...d.steps, line],
|
|
600
|
+
stepLine: ""
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function validateDraft(d) {
|
|
604
|
+
if (!resolvedApp(d)) return "App is required";
|
|
605
|
+
if (!d.action.trim()) return "Action is required";
|
|
606
|
+
if (!(Boolean(normalizeKeys(d.keys).trim()) || Boolean(d.command.trim()) || d.steps.length > 0)) return "Add keys, a command, or at least one step";
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
function draftToEntryInput(d) {
|
|
610
|
+
const e = { action: d.action.trim() };
|
|
611
|
+
const keys = normalizeKeys(d.keys).trim();
|
|
612
|
+
if (keys) e.keys = keys;
|
|
613
|
+
if (d.command.trim()) e.command = d.command.trim();
|
|
614
|
+
if (d.steps.length) e.steps = d.steps;
|
|
615
|
+
const tags = parseTags(d.tags);
|
|
616
|
+
if (tags.length) e.tags = tags;
|
|
617
|
+
if (d.notes.trim()) e.notes = d.notes.trim();
|
|
618
|
+
return e;
|
|
619
|
+
}
|
|
620
|
+
function useAddForm(initialApp = "") {
|
|
621
|
+
const [draft, setDraft] = useState(() => ({
|
|
622
|
+
...emptyDraft,
|
|
623
|
+
app: initialApp
|
|
624
|
+
}));
|
|
625
|
+
const update = (patch) => setDraft((d) => ({
|
|
626
|
+
...d,
|
|
627
|
+
...patch
|
|
628
|
+
}));
|
|
629
|
+
return {
|
|
630
|
+
draft,
|
|
631
|
+
update,
|
|
632
|
+
setDraft
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/tui/AddEntryForm.tsx
|
|
637
|
+
const TYPES = [
|
|
638
|
+
"shortcut",
|
|
639
|
+
"command",
|
|
640
|
+
"recipe"
|
|
641
|
+
];
|
|
642
|
+
const LAST_FIELD = 5;
|
|
643
|
+
function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget }) {
|
|
644
|
+
const { draft, update, setDraft } = useAddForm(apps[0] ?? "");
|
|
645
|
+
const [focused, setFocused] = useState(0);
|
|
646
|
+
const [appIndex, setAppIndex] = useState(0);
|
|
647
|
+
const [screen, setScreen] = useState("form");
|
|
648
|
+
const [hint, setHint] = useState("");
|
|
649
|
+
const [writeError, setWriteError] = useState("");
|
|
650
|
+
const appChoices = [...apps, "Create new app…"];
|
|
651
|
+
function commitAppSelection(index) {
|
|
652
|
+
if (index === apps.length) update({ creatingApp: true });
|
|
653
|
+
else update({
|
|
654
|
+
creatingApp: false,
|
|
655
|
+
app: apps[index] ?? ""
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
function goReview() {
|
|
659
|
+
const d = flushStep(draft);
|
|
660
|
+
const v = validateDraft(d);
|
|
661
|
+
if (v) {
|
|
662
|
+
setHint(v);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
setHint("");
|
|
666
|
+
setDraft(d);
|
|
667
|
+
setScreen("review");
|
|
668
|
+
}
|
|
669
|
+
const review = flushStep(draft);
|
|
670
|
+
const target = resolveTarget?.(resolvedApp(review));
|
|
671
|
+
const targetPath = target ? `${basename(target.file)}${target.created ? " (new)" : ""}` : `${resolvedApp(review)} file`;
|
|
672
|
+
useInput((input, key) => {
|
|
673
|
+
if (screen === "review") {
|
|
674
|
+
if (key.escape) return onCancel();
|
|
675
|
+
if (input === "e") return setScreen("form");
|
|
676
|
+
if (key.return) {
|
|
677
|
+
const result = onSubmit(resolvedApp(review), draftToEntryInput(review));
|
|
678
|
+
if (result.ok) onComplete?.(result);
|
|
679
|
+
else setWriteError(result.lines.join("; "));
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (key.escape) return onCancel();
|
|
684
|
+
if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
|
|
685
|
+
if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, 0));
|
|
686
|
+
if (focused === 0) {
|
|
687
|
+
if (key.upArrow) {
|
|
688
|
+
const next = Math.max(appIndex - 1, 0);
|
|
689
|
+
setAppIndex(next);
|
|
690
|
+
return commitAppSelection(next);
|
|
691
|
+
}
|
|
692
|
+
if (key.downArrow) {
|
|
693
|
+
const next = Math.min(appIndex + 1, appChoices.length - 1);
|
|
694
|
+
setAppIndex(next);
|
|
695
|
+
return commitAppSelection(next);
|
|
696
|
+
}
|
|
697
|
+
if (draft.creatingApp) {
|
|
698
|
+
if (key.return) return setFocused(1);
|
|
699
|
+
if (key.backspace || key.delete) return update({ newApp: draft.newApp.slice(0, -1) });
|
|
700
|
+
if (input && !key.ctrl && !key.meta) return update({ newApp: draft.newApp + input });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (key.return) return setFocused(1);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (focused === 1) {
|
|
707
|
+
if (key.leftArrow || key.rightArrow || input === " ") {
|
|
708
|
+
const idx = TYPES.indexOf(draft.type);
|
|
709
|
+
return update({ type: TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] });
|
|
710
|
+
}
|
|
711
|
+
if (key.return) return goReview();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (focused === 3 && draft.type === "recipe") {
|
|
715
|
+
if (key.return) {
|
|
716
|
+
if (draft.stepLine.trim()) update({
|
|
717
|
+
steps: [...draft.steps, draft.stepLine.trim()],
|
|
718
|
+
stepLine: ""
|
|
719
|
+
});
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (key.backspace || key.delete) {
|
|
723
|
+
if (draft.stepLine) return update({ stepLine: draft.stepLine.slice(0, -1) });
|
|
724
|
+
return update({ steps: draft.steps.slice(0, -1) });
|
|
725
|
+
}
|
|
726
|
+
if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (key.return) return goReview();
|
|
730
|
+
const fieldKey = focused === 2 ? "action" : focused === 3 ? draft.type === "command" ? "command" : "keys" : focused === 4 ? "tags" : "notes";
|
|
731
|
+
if (key.backspace || key.delete) return update({ [fieldKey]: draft[fieldKey].slice(0, -1) });
|
|
732
|
+
if (input && !key.ctrl && !key.meta) return update({ [fieldKey]: draft[fieldKey] + input });
|
|
733
|
+
});
|
|
734
|
+
if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
|
|
735
|
+
app: resolvedApp(review),
|
|
736
|
+
entry: draftToEntryInput(review),
|
|
737
|
+
targetPath,
|
|
738
|
+
error: writeError
|
|
739
|
+
});
|
|
740
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
741
|
+
flexDirection: "column",
|
|
742
|
+
children: [
|
|
743
|
+
/* @__PURE__ */ jsx(Text, {
|
|
744
|
+
color: "cyan",
|
|
745
|
+
children: "keybook add"
|
|
746
|
+
}),
|
|
747
|
+
/* @__PURE__ */ jsx(Box, {
|
|
748
|
+
marginTop: 1,
|
|
749
|
+
children: /* @__PURE__ */ jsx(FormFields, {
|
|
750
|
+
draft,
|
|
751
|
+
apps,
|
|
752
|
+
appIndex,
|
|
753
|
+
focused,
|
|
754
|
+
existingTags
|
|
755
|
+
})
|
|
756
|
+
}),
|
|
757
|
+
/* @__PURE__ */ jsx(Box, {
|
|
758
|
+
marginTop: 1,
|
|
759
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
760
|
+
color: hint ? "red" : "gray",
|
|
761
|
+
children: hint || "⌃N next · ⌃P prev · ⏎ review · esc cancel"
|
|
762
|
+
})
|
|
763
|
+
})
|
|
764
|
+
]
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region src/clipboard.ts
|
|
769
|
+
/** Copy text to the macOS clipboard via pbcopy. Returns false if it fails. */
|
|
770
|
+
function copyToClipboard(text) {
|
|
771
|
+
try {
|
|
772
|
+
return spawnSync("pbcopy", { input: text }).status === 0;
|
|
773
|
+
} catch {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
778
|
+
//#region src/search.ts
|
|
779
|
+
function haystack(e) {
|
|
780
|
+
return [
|
|
781
|
+
e.action,
|
|
782
|
+
(e.tags ?? []).join(" "),
|
|
783
|
+
e.app,
|
|
784
|
+
e.keys ?? "",
|
|
785
|
+
e.notes ?? ""
|
|
786
|
+
].filter(Boolean).join(" ");
|
|
787
|
+
}
|
|
788
|
+
/** Pure fuzzy search. Empty query → all entries in stable browse order. */
|
|
789
|
+
function search(entries, query) {
|
|
790
|
+
const q = query.trim();
|
|
791
|
+
if (!q) return [...entries].sort((a, b) => a.app.localeCompare(b.app) || a.action.localeCompare(b.action));
|
|
792
|
+
return new Fzf(entries, {
|
|
793
|
+
selector: haystack,
|
|
794
|
+
match: extendedMatch
|
|
795
|
+
}).find(q).map((r) => r.item);
|
|
226
796
|
}
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/tui/Footer.tsx
|
|
799
|
+
function Footer({ flash, errorCount, resultCount }) {
|
|
800
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
801
|
+
marginTop: 1,
|
|
802
|
+
justifyContent: "space-between",
|
|
803
|
+
children: [/* @__PURE__ */ jsxs(Text, {
|
|
804
|
+
color: "gray",
|
|
805
|
+
children: [
|
|
806
|
+
"↑↓ move ⏎ copy ⌃O add ⎋ quit ⌥⌫/⌃W del ⌃U clear (",
|
|
807
|
+
resultCount,
|
|
808
|
+
")"
|
|
809
|
+
]
|
|
810
|
+
}), flash ? /* @__PURE__ */ jsx(Text, {
|
|
811
|
+
color: "green",
|
|
812
|
+
children: flash
|
|
813
|
+
}) : errorCount > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
814
|
+
color: "yellow",
|
|
815
|
+
children: [
|
|
816
|
+
"⚠ ",
|
|
817
|
+
errorCount,
|
|
818
|
+
" skipped — run `keybook check`"
|
|
819
|
+
]
|
|
820
|
+
}) : /* @__PURE__ */ jsx(Text, { children: " " })]
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
//#endregion
|
|
824
|
+
//#region src/tui/PreviewPane.tsx
|
|
227
825
|
function PreviewPane({ entry, width = "50%" }) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
826
|
+
if (!entry) return null;
|
|
827
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
828
|
+
flexDirection: "column",
|
|
829
|
+
width,
|
|
830
|
+
paddingLeft: 2,
|
|
831
|
+
children: [
|
|
832
|
+
/* @__PURE__ */ jsx(Text, {
|
|
833
|
+
color: "cyan",
|
|
834
|
+
children: entry.app
|
|
835
|
+
}),
|
|
836
|
+
/* @__PURE__ */ jsx(Text, {
|
|
837
|
+
bold: true,
|
|
838
|
+
children: entry.action
|
|
839
|
+
}),
|
|
840
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
841
|
+
marginTop: 1,
|
|
842
|
+
flexDirection: "column",
|
|
843
|
+
children: [
|
|
844
|
+
entry.keys ? /* @__PURE__ */ jsx(KeyCaps, { value: entry.keys }) : null,
|
|
845
|
+
entry.steps?.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
846
|
+
i + 1,
|
|
847
|
+
". ",
|
|
848
|
+
s
|
|
849
|
+
] }, s)),
|
|
850
|
+
entry.command ? /* @__PURE__ */ jsxs(Text, {
|
|
851
|
+
color: "green",
|
|
852
|
+
children: ["$ ", entry.command]
|
|
853
|
+
}) : null
|
|
854
|
+
]
|
|
855
|
+
}),
|
|
856
|
+
entry.tags?.length ? /* @__PURE__ */ jsxs(Text, {
|
|
857
|
+
color: "gray",
|
|
858
|
+
children: ["tags: ", entry.tags.join(", ")]
|
|
859
|
+
}) : null,
|
|
860
|
+
entry.notes ? /* @__PURE__ */ jsx(Text, {
|
|
861
|
+
color: "gray",
|
|
862
|
+
children: entry.notes
|
|
863
|
+
}) : null
|
|
864
|
+
]
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/tui/ResultRow.tsx
|
|
258
869
|
function ResultRow({ entry, selected }) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
function ResultList({
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// src/tui/SearchInput.tsx
|
|
303
|
-
import { Box as Box5, Text as Text5 } from "ink";
|
|
304
|
-
import { jsx as jsx4, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
870
|
+
const right = entry.keys ?? "recipe";
|
|
871
|
+
return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
|
|
872
|
+
color: selected ? "cyan" : void 0,
|
|
873
|
+
inverse: selected,
|
|
874
|
+
children: [
|
|
875
|
+
selected ? "▸ " : " ",
|
|
876
|
+
entry.app,
|
|
877
|
+
" · ",
|
|
878
|
+
entry.action
|
|
879
|
+
]
|
|
880
|
+
}), /* @__PURE__ */ jsxs(Text, {
|
|
881
|
+
color: "gray",
|
|
882
|
+
children: [" ", right]
|
|
883
|
+
})] });
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/tui/ResultList.tsx
|
|
887
|
+
function ResultList({ results, selected, query, height = 12, width = "50%" }) {
|
|
888
|
+
if (results.length === 0) return /* @__PURE__ */ jsx(Box, {
|
|
889
|
+
width,
|
|
890
|
+
justifyContent: "center",
|
|
891
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
892
|
+
color: "gray",
|
|
893
|
+
children: [
|
|
894
|
+
"No matches for \"",
|
|
895
|
+
query,
|
|
896
|
+
"\""
|
|
897
|
+
]
|
|
898
|
+
})
|
|
899
|
+
});
|
|
900
|
+
const start = Math.max(0, Math.min(selected - Math.floor(height / 2), results.length - height));
|
|
901
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
902
|
+
flexDirection: "column",
|
|
903
|
+
width,
|
|
904
|
+
children: results.slice(Math.max(0, start), Math.max(0, start) + height).map((e, i) => /* @__PURE__ */ jsx(ResultRow, {
|
|
905
|
+
entry: e,
|
|
906
|
+
selected: Math.max(0, start) + i === selected
|
|
907
|
+
}, `${e.app}:${e.action}`))
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
//#endregion
|
|
911
|
+
//#region src/tui/SearchInput.tsx
|
|
305
912
|
function SearchInput({ query }) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
913
|
+
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
914
|
+
/* @__PURE__ */ jsx(Text, {
|
|
915
|
+
color: "cyan",
|
|
916
|
+
children: "search: "
|
|
917
|
+
}),
|
|
918
|
+
/* @__PURE__ */ jsx(Text, { children: query }),
|
|
919
|
+
/* @__PURE__ */ jsx(Text, {
|
|
920
|
+
inverse: true,
|
|
921
|
+
children: " "
|
|
922
|
+
})
|
|
923
|
+
] });
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
//#region src/tui/input.ts
|
|
927
|
+
/**
|
|
928
|
+
* Drop the run of trailing whitespace, then the run of trailing non-whitespace.
|
|
929
|
+
* Mirrors readline's `unix-word-rubout` / `backward-kill-word` behavior so a
|
|
930
|
+
* single press eats both the spaces and the word immediately before them.
|
|
931
|
+
*/
|
|
314
932
|
function deleteWordBack(query) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
933
|
+
const trimmed = query.replace(/\s+$/, "");
|
|
934
|
+
const lastSpace = trimmed.lastIndexOf(" ");
|
|
935
|
+
if (lastSpace === -1) return "";
|
|
936
|
+
return trimmed.slice(0, lastSpace + 1);
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Rows to give the windowed result list, sized to the terminal height so the
|
|
940
|
+
* whole frame fits the viewport. Ink can only redraw in place while the frame
|
|
941
|
+
* stays within the terminal; an oversized frame duplicates on every keypress.
|
|
942
|
+
*/
|
|
323
943
|
function visibleListHeight(terminalRows) {
|
|
324
|
-
|
|
325
|
-
return Math.max(1, rows - RESERVED_ROWS);
|
|
944
|
+
return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
|
|
326
945
|
}
|
|
946
|
+
/**
|
|
947
|
+
* Integer widths for the two side-by-side panes. They MUST sum to the exact
|
|
948
|
+
* terminal width: two `width="50%"` siblings each round up on an odd-width
|
|
949
|
+
* terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
|
|
950
|
+
* then soft-wraps the full-width rows, Ink miscounts the frame height, and its
|
|
951
|
+
* redraw leaves stale lines stacked on every keystroke.
|
|
952
|
+
*/
|
|
327
953
|
function columnWidths(terminalCols) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
954
|
+
const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
|
|
955
|
+
const left = Math.floor(cols / 2);
|
|
956
|
+
return {
|
|
957
|
+
left,
|
|
958
|
+
right: cols - left
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/tui/App.tsx
|
|
963
|
+
function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
|
|
964
|
+
const { exit } = useApp();
|
|
965
|
+
const { stdout } = useStdout();
|
|
966
|
+
const [entries, setEntries] = useState(initial);
|
|
967
|
+
const [mode, setMode] = useState("search");
|
|
968
|
+
const [query, setQuery] = useState("");
|
|
969
|
+
const [selected, setSelected] = useState(0);
|
|
970
|
+
const [flash, setFlash] = useState("");
|
|
971
|
+
const reload = useCallback(() => {
|
|
972
|
+
if (dataDir) setEntries(loadEntries(dataDir).entries);
|
|
973
|
+
}, [dataDir]);
|
|
974
|
+
const results = useMemo(() => search(entries, query), [entries, query]);
|
|
975
|
+
const sel = results.length ? Math.min(selected, results.length - 1) : 0;
|
|
976
|
+
const current = results[sel];
|
|
977
|
+
const listHeight = visibleListHeight(stdout?.rows);
|
|
978
|
+
const { left, right } = columnWidths(stdout?.columns);
|
|
979
|
+
useInput((input, key) => {
|
|
980
|
+
if (!key.return && flash) setFlash("");
|
|
981
|
+
if (key.escape || key.ctrl && input === "c") {
|
|
982
|
+
exit();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (dataDir && key.ctrl && input === "o") {
|
|
986
|
+
setMode("add");
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (key.downArrow || key.ctrl && input === "n") {
|
|
990
|
+
setSelected((s) => Math.min(s + 1, results.length - 1));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (key.upArrow || key.ctrl && input === "p") {
|
|
994
|
+
setSelected((s) => Math.max(s - 1, 0));
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (key.return) {
|
|
998
|
+
if (current) setFlash(onCopy(current.keys ?? current.command ?? `${current.app}: ${current.action}`) ? "✓ copied!" : "✗ copy failed");
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
|
|
1002
|
+
setQuery(deleteWordBack);
|
|
1003
|
+
setSelected(0);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (key.ctrl && input === "u") {
|
|
1007
|
+
setQuery("");
|
|
1008
|
+
setSelected(0);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (key.backspace || key.delete) {
|
|
1012
|
+
setQuery((q) => q.slice(0, -1));
|
|
1013
|
+
setSelected(0);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1017
|
+
setQuery((q) => q + input);
|
|
1018
|
+
setSelected(0);
|
|
1019
|
+
}
|
|
1020
|
+
}, { isActive: mode === "search" });
|
|
1021
|
+
if (mode === "add" && dataDir) {
|
|
1022
|
+
const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
|
|
1023
|
+
return /* @__PURE__ */ jsx(AddEntryForm, {
|
|
1024
|
+
apps: listApps(dataDir),
|
|
1025
|
+
existingTags,
|
|
1026
|
+
resolveTarget: (a) => resolveTargetFile(dataDir, a),
|
|
1027
|
+
onSubmit: (app, entry) => addEntry(dataDir, app, entry),
|
|
1028
|
+
onComplete: (result) => {
|
|
1029
|
+
if (result.ok) {
|
|
1030
|
+
reload();
|
|
1031
|
+
setSelected(0);
|
|
1032
|
+
setFlash(result.lines[0] ?? "✓ added");
|
|
1033
|
+
}
|
|
1034
|
+
setMode("search");
|
|
1035
|
+
},
|
|
1036
|
+
onCancel: () => setMode("search")
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
1040
|
+
flexDirection: "column",
|
|
1041
|
+
children: [
|
|
1042
|
+
/* @__PURE__ */ jsx(SearchInput, { query }),
|
|
1043
|
+
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
|
|
1044
|
+
results,
|
|
1045
|
+
selected: sel,
|
|
1046
|
+
query,
|
|
1047
|
+
height: listHeight,
|
|
1048
|
+
width: left
|
|
1049
|
+
}), /* @__PURE__ */ jsx(PreviewPane, {
|
|
1050
|
+
entry: current,
|
|
1051
|
+
width: right
|
|
1052
|
+
})] }),
|
|
1053
|
+
/* @__PURE__ */ jsx(Footer, {
|
|
1054
|
+
flash,
|
|
1055
|
+
errorCount,
|
|
1056
|
+
resultCount: results.length
|
|
1057
|
+
})
|
|
1058
|
+
]
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region src/cli.ts
|
|
1063
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1064
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
1065
|
+
const program = new Command();
|
|
410
1066
|
program.name("keybook").description("Search keyboard shortcuts & recipes for your apps").version(pkg.version);
|
|
411
1067
|
program.command("path").description("Print the data directory").action(() => {
|
|
412
|
-
|
|
1068
|
+
console.log(runPath());
|
|
413
1069
|
});
|
|
414
1070
|
program.command("edit").description("Open the data directory in $EDITOR").action(() => {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const child = spawn(editor, [dir], { stdio: "inherit" });
|
|
419
|
-
child.on("error", () => spawn("open", [dir], { stdio: "inherit" }));
|
|
1071
|
+
const { dir } = resolveDataDir();
|
|
1072
|
+
ensureDataDir(dir);
|
|
1073
|
+
spawn(process.env.EDITOR || "code", [dir], { stdio: "inherit" }).on("error", () => spawn("open", [dir], { stdio: "inherit" }));
|
|
420
1074
|
});
|
|
421
1075
|
program.command("check").description("Validate all data files").action(() => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1076
|
+
const { dir } = resolveDataDir();
|
|
1077
|
+
ensureDataDir(dir);
|
|
1078
|
+
const result = runCheck(dir);
|
|
1079
|
+
for (const line of result.lines) (result.ok ? console.log : console.error)(line);
|
|
1080
|
+
process.exit(result.ok ? 0 : 1);
|
|
1081
|
+
});
|
|
1082
|
+
program.command("add").description("Add a new entry (interactive, or non-interactive with flags)").option("--app <name>", "target app").option("--action <text>", "what the entry does").option("--keys <combo>", "key combo (glyphs or words, e.g. 'shift cmd p')").option("--command <cmd>", "terminal command").option("--step <text...>", "recipe step (repeatable)").option("--tags <list>", "comma-separated tags").option("--notes <text>", "notes").action((opts) => {
|
|
1083
|
+
const { dir } = resolveDataDir();
|
|
1084
|
+
try {
|
|
1085
|
+
ensureDataDir(dir);
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
console.error(`Error: could not prepare data directory ${dir}: ${e.message}`);
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
const app = opts.app;
|
|
1091
|
+
const action = opts.action;
|
|
1092
|
+
const keys = opts.keys;
|
|
1093
|
+
const command = opts.command;
|
|
1094
|
+
const steps = opts.step ?? void 0;
|
|
1095
|
+
const tags = typeof opts.tags === "string" ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : void 0;
|
|
1096
|
+
const notes = opts.notes;
|
|
1097
|
+
const haveBody = Boolean(keys || command || steps?.length);
|
|
1098
|
+
if (Boolean(app && action && haveBody)) {
|
|
1099
|
+
const result = runAdd(dir, {
|
|
1100
|
+
app,
|
|
1101
|
+
action,
|
|
1102
|
+
keys,
|
|
1103
|
+
command,
|
|
1104
|
+
steps,
|
|
1105
|
+
tags,
|
|
1106
|
+
notes
|
|
1107
|
+
});
|
|
1108
|
+
for (const line of result.lines) (result.ok ? console.log : console.error)(line);
|
|
1109
|
+
process.exit(result.ok ? 0 : 1);
|
|
1110
|
+
}
|
|
1111
|
+
if (!process.stdout.isTTY) {
|
|
1112
|
+
const missing = [
|
|
1113
|
+
!app && "--app",
|
|
1114
|
+
!action && "--action",
|
|
1115
|
+
!haveBody && "--keys/--command/--step"
|
|
1116
|
+
].filter(Boolean).join(", ");
|
|
1117
|
+
console.error(`Error: missing required field(s): ${missing}`);
|
|
1118
|
+
process.exit(2);
|
|
1119
|
+
}
|
|
1120
|
+
const { entries } = loadEntries(dir);
|
|
1121
|
+
const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
|
|
1122
|
+
const instance = render(createElement(AddEntryForm, {
|
|
1123
|
+
apps: listApps(dir),
|
|
1124
|
+
existingTags,
|
|
1125
|
+
resolveTarget: (a) => resolveTargetFile(dir, a),
|
|
1126
|
+
onSubmit: (a, entry) => addEntry(dir, a, entry),
|
|
1127
|
+
onComplete: (result) => {
|
|
1128
|
+
if (result.lines[0]) console.log(result.lines[0]);
|
|
1129
|
+
instance.unmount();
|
|
1130
|
+
},
|
|
1131
|
+
onCancel: () => instance.unmount()
|
|
1132
|
+
}));
|
|
1133
|
+
instance.waitUntilExit().then(() => process.exit(0));
|
|
427
1134
|
});
|
|
428
1135
|
program.action(() => {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
1136
|
+
const { dir } = resolveDataDir();
|
|
1137
|
+
const init = ensureDataDir(dir);
|
|
1138
|
+
if (init.initialized) console.log(`Initialized ${dir} from seed (${init.fileCount} files).`);
|
|
1139
|
+
const { entries, errors } = loadEntries(dir);
|
|
1140
|
+
for (const e of errors) console.error(`⚠ ${e.file}: ${e.message}`);
|
|
1141
|
+
render(createElement(App, {
|
|
1142
|
+
entries,
|
|
1143
|
+
errorCount: errors.length,
|
|
1144
|
+
dataDir: dir
|
|
1145
|
+
}));
|
|
435
1146
|
});
|
|
436
1147
|
program.parse();
|
|
1148
|
+
//#endregion
|
|
1149
|
+
export {};
|