@arthony/keybook 0.6.0 → 0.8.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 +22 -3
- package/dist/cli.js +703 -174
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -25,9 +25,10 @@ Requires macOS. The Homebrew tap installs Node 22 automatically; the npm
|
|
|
25
25
|
route assumes you already have Node 22+. Both install the `keybook`
|
|
26
26
|
command and the `kb` alias.
|
|
27
27
|
|
|
28
|
-
**Terminal.app users:** if
|
|
29
|
-
enable *Terminal → Settings → Profiles → Keyboard → "Use Option as
|
|
30
|
-
key"
|
|
28
|
+
**Terminal.app users:** if `⌥⌫`, `⌥←` or `⌥→` don't work in the search
|
|
29
|
+
input, enable *Terminal → Settings → Profiles → Keyboard → "Use Option as
|
|
30
|
+
Meta key"*. `⌃W` (delete word back) works in any terminal; there is no
|
|
31
|
+
Option-free equivalent for word-wise cursor movement.
|
|
31
32
|
|
|
32
33
|
## Usage
|
|
33
34
|
|
|
@@ -47,6 +48,24 @@ selected entry (pre-filled form), `⌃X` to delete it (with a `y/n` confirm). Or
|
|
|
47
48
|
script an add: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
|
|
48
49
|
(`--keys` accepts glyphs or words; recipes use repeatable `--step`).
|
|
49
50
|
|
|
51
|
+
### Text editing
|
|
52
|
+
|
|
53
|
+
Every text input — the search box and each field of the add/edit form — takes
|
|
54
|
+
the same readline-style bindings:
|
|
55
|
+
|
|
56
|
+
| Key | Does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `←` / `→` | move one character |
|
|
59
|
+
| `⌥←` / `⌥→` | move one word |
|
|
60
|
+
| `⌃A` / `⌃E` | jump to start / end of the line |
|
|
61
|
+
| `⌫` / `⌃D` | delete before / at the cursor |
|
|
62
|
+
| `⌥⌫` / `⌃W` | delete the word before the cursor |
|
|
63
|
+
| `⌃U` / `⌃K` | delete to the start / end of the line |
|
|
64
|
+
|
|
65
|
+
`⌃E` is the one exception: in the **search box** it opens the edit form for the
|
|
66
|
+
selected entry (there is no conflict for `⌃A`, which works everywhere), and on
|
|
67
|
+
a **selected recipe step** it opens that step for in-place editing.
|
|
68
|
+
|
|
50
69
|
## Your data
|
|
51
70
|
|
|
52
71
|
Shortcuts live in plain YAML files you own — by default `~/.config/keybook/`
|
package/dist/cli.js
CHANGED
|
@@ -5,11 +5,11 @@ import { basename, dirname, join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { Box, Text, render, useApp, useInput, useStdout } from "ink";
|
|
8
|
-
import { createElement, useCallback, useMemo, useState } from "react";
|
|
8
|
+
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { Document, isMap, isSeq, parse, parseDocument } from "yaml";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
import {
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
13
|
import { Fzf, extendedMatch } from "fzf";
|
|
14
14
|
//#region src/config.ts
|
|
15
15
|
const YAML_RE$2 = /\.ya?ml$/;
|
|
@@ -379,6 +379,23 @@ function editEntry(dir, file, index, entry, expectedAction) {
|
|
|
379
379
|
lines: [`✓ updated '${clean.action}'`]
|
|
380
380
|
};
|
|
381
381
|
}
|
|
382
|
+
function moveEntry(dir, sourceFile, index, expectedAction, targetApp, entry) {
|
|
383
|
+
if (resolveTargetFile(dir, targetApp).file === join(dir, sourceFile)) return editEntry(dir, sourceFile, index, entry, expectedAction);
|
|
384
|
+
const addRes = addEntry(dir, targetApp, entry);
|
|
385
|
+
if (!addRes.ok) return addRes;
|
|
386
|
+
if (!deleteEntry(dir, sourceFile, index, expectedAction).ok) return {
|
|
387
|
+
ok: true,
|
|
388
|
+
file: addRes.file,
|
|
389
|
+
created: addRes.created,
|
|
390
|
+
lines: [`⚠ moved to ${targetApp}; original still in ${sourceFile} — remove it manually`]
|
|
391
|
+
};
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
file: addRes.file,
|
|
395
|
+
created: addRes.created,
|
|
396
|
+
lines: [`✓ moved '${entry.action}' → ${targetApp}`]
|
|
397
|
+
};
|
|
398
|
+
}
|
|
382
399
|
//#endregion
|
|
383
400
|
//#region src/commands.ts
|
|
384
401
|
function runPath(env = process.env) {
|
|
@@ -411,30 +428,109 @@ function runAdd(dir, draft) {
|
|
|
411
428
|
};
|
|
412
429
|
}
|
|
413
430
|
//#endregion
|
|
431
|
+
//#region src/tui/CursorText.tsx
|
|
432
|
+
/**
|
|
433
|
+
* Renders a value with a block cursor at `pos`, windowed horizontally so the
|
|
434
|
+
* cursor is always visible and the row NEVER wraps.
|
|
435
|
+
*
|
|
436
|
+
* Window rule (stateless): the view stays anchored at the left until the
|
|
437
|
+
* cursor would fall off the right edge, then scrolls one character at a time.
|
|
438
|
+
* A pure function of (pos, width) — no stored scroll offset to keep in sync.
|
|
439
|
+
*
|
|
440
|
+
* There is deliberately no clipped-content indicator: rendering a marker plus
|
|
441
|
+
* `width` cells is width+1 columns, which wraps — the exact failure this
|
|
442
|
+
* component exists to prevent. See the design spec §7.
|
|
443
|
+
*
|
|
444
|
+
* `key={chars.length}` is load-bearing, not decorative. Ink's Yoga layout
|
|
445
|
+
* measures a `wrap="truncate-end"` Text node's width incrementally, and that
|
|
446
|
+
* measurement is one render stale relative to a code-point-count change: the
|
|
447
|
+
* FIRST render after this Text's content grows (e.g. typing a character into
|
|
448
|
+
* an empty focused field) uses the PRIOR frame's — too-narrow — measured
|
|
449
|
+
* width, so it clips to a bare "…" and, since nothing re-renders without
|
|
450
|
+
* another keystroke, a real terminal would leave that "…" on screen
|
|
451
|
+
* indefinitely. Keying on the code-point count forces React (and Ink's host
|
|
452
|
+
* reconciler) to unmount the old node and mount a fresh one whenever the
|
|
453
|
+
* length changes, so there is no stale prior measurement to be wrong: a
|
|
454
|
+
* fresh node's first layout pass measures the current content correctly.
|
|
455
|
+
* `wrap="truncate-end"` is kept as the defensive backstop (matches the
|
|
456
|
+
* "windowed content never exceeds `width` cells" invariant above); without
|
|
457
|
+
* the key, it is the thing that makes the staleness visible as clipping
|
|
458
|
+
* instead of silently wrapping onto a second row and corrupting whatever
|
|
459
|
+
* renders below.
|
|
460
|
+
*/
|
|
461
|
+
function CursorText({ value, pos, width, focused }) {
|
|
462
|
+
const chars = [...value];
|
|
463
|
+
const w = Math.max(1, width);
|
|
464
|
+
if (!focused) return /* @__PURE__ */ jsx(Text, {
|
|
465
|
+
wrap: "truncate-end",
|
|
466
|
+
children: chars.slice(0, w).join("")
|
|
467
|
+
}, chars.length);
|
|
468
|
+
const p = Math.max(0, Math.min(pos, chars.length));
|
|
469
|
+
const start = p < w ? 0 : p - w + 1;
|
|
470
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
471
|
+
wrap: "truncate-end",
|
|
472
|
+
children: [
|
|
473
|
+
chars.slice(start, p).join(""),
|
|
474
|
+
/* @__PURE__ */ jsx(Text, {
|
|
475
|
+
inverse: true,
|
|
476
|
+
children: chars[p] ?? " "
|
|
477
|
+
}),
|
|
478
|
+
chars.slice(p + 1, start + w).join("")
|
|
479
|
+
]
|
|
480
|
+
}, chars.length);
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
414
483
|
//#region src/tui/StepsBuilder.tsx
|
|
415
|
-
function StepsBuilder({ steps, line, active }) {
|
|
484
|
+
function StepsBuilder({ steps, line, active, cursor, grabbed = false, pos, width, editingIndex, editBuffer }) {
|
|
485
|
+
const cur = cursor ?? steps.length;
|
|
486
|
+
const onAppendLine = cur >= steps.length;
|
|
487
|
+
const isEditing = editingIndex !== void 0;
|
|
488
|
+
const w = width ?? 71;
|
|
489
|
+
const hint = isEditing ? "⏎ save · esc cancel" : grabbed ? "↑↓ move · Space/⏎ drop" : steps.length === 0 ? "⏎ adds a step · ⌫ on an empty line removes the last" : onAppendLine ? "⏎ adds a step · ↑ select a step to reorder" : "↑↓ select · Space grab · ⌫ delete · ⌃E edit";
|
|
416
490
|
return /* @__PURE__ */ jsxs(Box, {
|
|
417
491
|
flexDirection: "column",
|
|
418
492
|
children: [
|
|
419
|
-
steps.map((s, i) => /* @__PURE__ */ jsxs(
|
|
493
|
+
steps.map((s, i) => i === editingIndex ? /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
|
|
494
|
+
color: "cyan",
|
|
495
|
+
children: [
|
|
496
|
+
" ",
|
|
497
|
+
i + 1,
|
|
498
|
+
".",
|
|
499
|
+
" "
|
|
500
|
+
]
|
|
501
|
+
}), /* @__PURE__ */ jsx(CursorText, {
|
|
502
|
+
value: editBuffer ?? "",
|
|
503
|
+
pos: pos ?? [...editBuffer ?? ""].length,
|
|
504
|
+
width: w,
|
|
505
|
+
focused: true
|
|
506
|
+
})] }, `${i}-edit`) : /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, { children: [
|
|
507
|
+
active && cur === i ? grabbed ? "⇅ " : "> " : " ",
|
|
420
508
|
i + 1,
|
|
421
|
-
".
|
|
422
|
-
|
|
423
|
-
] },
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
active ?
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
509
|
+
".",
|
|
510
|
+
" "
|
|
511
|
+
] }), /* @__PURE__ */ jsx(CursorText, {
|
|
512
|
+
value: s,
|
|
513
|
+
pos: 0,
|
|
514
|
+
width: w,
|
|
515
|
+
focused: false
|
|
516
|
+
})] }, i)),
|
|
517
|
+
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
|
|
518
|
+
color: active && onAppendLine ? "cyan" : "gray",
|
|
519
|
+
children: [
|
|
520
|
+
" ",
|
|
521
|
+
steps.length + 1,
|
|
522
|
+
".",
|
|
523
|
+
" "
|
|
524
|
+
]
|
|
525
|
+
}), /* @__PURE__ */ jsx(CursorText, {
|
|
526
|
+
value: line,
|
|
527
|
+
pos: pos ?? [...line].length,
|
|
528
|
+
width: w,
|
|
529
|
+
focused: active && onAppendLine
|
|
530
|
+
})] }),
|
|
531
|
+
active ? /* @__PURE__ */ jsx(Text, {
|
|
436
532
|
color: "gray",
|
|
437
|
-
children:
|
|
533
|
+
children: hint
|
|
438
534
|
}) : null
|
|
439
535
|
]
|
|
440
536
|
});
|
|
@@ -446,34 +542,32 @@ const TYPES$1 = [
|
|
|
446
542
|
"command",
|
|
447
543
|
"recipe"
|
|
448
544
|
];
|
|
449
|
-
function Field({ label, value, focused }) {
|
|
450
|
-
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp }) {
|
|
545
|
+
function Field({ label, value, focused, pos, width }) {
|
|
546
|
+
return /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
547
|
+
color: focused ? "cyan" : "gray",
|
|
548
|
+
children: label.padEnd(8)
|
|
549
|
+
}), /* @__PURE__ */ jsx(CursorText, {
|
|
550
|
+
value,
|
|
551
|
+
pos,
|
|
552
|
+
width,
|
|
553
|
+
focused
|
|
554
|
+
})] });
|
|
555
|
+
}
|
|
556
|
+
function FormFields({ draft, apps, appIndex, focused, pos, width, existingTags, stepCursor, grabbed, editingStep, editBuffer }) {
|
|
463
557
|
const appChoices = [...apps, "Create new app…"];
|
|
558
|
+
const keysPreview = draft.keys.trim() ? ` → ${normalizeKeys(draft.keys)}` : "";
|
|
464
559
|
return /* @__PURE__ */ jsxs(Box, {
|
|
465
560
|
flexDirection: "column",
|
|
466
561
|
children: [
|
|
467
562
|
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
468
563
|
color: focused === 0 ? "cyan" : "gray",
|
|
469
564
|
children: "App".padEnd(8)
|
|
470
|
-
}),
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
|
|
565
|
+
}), draft.creatingApp ? /* @__PURE__ */ jsx(CursorText, {
|
|
566
|
+
value: draft.newApp,
|
|
567
|
+
pos,
|
|
568
|
+
width,
|
|
569
|
+
focused: focused === 0
|
|
570
|
+
}) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
|
|
477
571
|
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
478
572
|
color: focused === 1 ? "cyan" : "gray",
|
|
479
573
|
children: "Type".padEnd(8)
|
|
@@ -481,27 +575,38 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
|
|
|
481
575
|
/* @__PURE__ */ jsx(Field, {
|
|
482
576
|
label: "Action",
|
|
483
577
|
value: draft.action,
|
|
484
|
-
focused: focused === 2
|
|
578
|
+
focused: focused === 2,
|
|
579
|
+
pos,
|
|
580
|
+
width
|
|
485
581
|
}),
|
|
486
582
|
draft.type === "shortcut" ? /* @__PURE__ */ jsxs(Box, { children: [
|
|
487
583
|
/* @__PURE__ */ jsx(Text, {
|
|
488
584
|
color: focused === 3 ? "cyan" : "gray",
|
|
489
585
|
children: "Keys".padEnd(8)
|
|
490
586
|
}),
|
|
491
|
-
/* @__PURE__ */ jsx(
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
587
|
+
/* @__PURE__ */ jsx(CursorText, {
|
|
588
|
+
value: draft.keys,
|
|
589
|
+
pos,
|
|
590
|
+
width: Math.max(8, width - keysPreview.length),
|
|
591
|
+
focused: focused === 3
|
|
592
|
+
}),
|
|
593
|
+
keysPreview ? /* @__PURE__ */ jsx(Text, {
|
|
497
594
|
color: "gray",
|
|
498
|
-
children:
|
|
595
|
+
children: keysPreview
|
|
499
596
|
}) : null
|
|
500
|
-
] }) : draft.type === "command" ? /* @__PURE__ */
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
597
|
+
] }) : draft.type === "command" ? /* @__PURE__ */ jsxs(Box, { children: [
|
|
598
|
+
/* @__PURE__ */ jsx(Text, {
|
|
599
|
+
color: focused === 3 ? "cyan" : "gray",
|
|
600
|
+
children: "Command".padEnd(8)
|
|
601
|
+
}),
|
|
602
|
+
/* @__PURE__ */ jsx(Text, { children: "$ " }),
|
|
603
|
+
/* @__PURE__ */ jsx(CursorText, {
|
|
604
|
+
value: draft.command,
|
|
605
|
+
pos,
|
|
606
|
+
width: width - 2,
|
|
607
|
+
focused: focused === 3
|
|
608
|
+
})
|
|
609
|
+
] }) : /* @__PURE__ */ jsxs(Box, {
|
|
505
610
|
flexDirection: "column",
|
|
506
611
|
children: [/* @__PURE__ */ jsx(Text, {
|
|
507
612
|
color: focused === 3 ? "cyan" : "gray",
|
|
@@ -509,13 +614,21 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
|
|
|
509
614
|
}), /* @__PURE__ */ jsx(StepsBuilder, {
|
|
510
615
|
steps: draft.steps,
|
|
511
616
|
line: draft.stepLine,
|
|
512
|
-
active: focused === 3
|
|
617
|
+
active: focused === 3,
|
|
618
|
+
cursor: stepCursor,
|
|
619
|
+
grabbed,
|
|
620
|
+
pos,
|
|
621
|
+
width,
|
|
622
|
+
editingIndex: editingStep ?? void 0,
|
|
623
|
+
editBuffer
|
|
513
624
|
})]
|
|
514
625
|
}),
|
|
515
626
|
/* @__PURE__ */ jsx(Field, {
|
|
516
627
|
label: "Tags",
|
|
517
628
|
value: draft.tags,
|
|
518
|
-
focused: focused === 4
|
|
629
|
+
focused: focused === 4,
|
|
630
|
+
pos,
|
|
631
|
+
width
|
|
519
632
|
}),
|
|
520
633
|
focused === 4 && existingTags && existingTags.length > 0 ? /* @__PURE__ */ jsx(Text, {
|
|
521
634
|
color: "gray",
|
|
@@ -524,7 +637,9 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
|
|
|
524
637
|
/* @__PURE__ */ jsx(Field, {
|
|
525
638
|
label: "Notes",
|
|
526
639
|
value: draft.notes,
|
|
527
|
-
focused: focused === 5
|
|
640
|
+
focused: focused === 5,
|
|
641
|
+
pos,
|
|
642
|
+
width
|
|
528
643
|
})
|
|
529
644
|
]
|
|
530
645
|
});
|
|
@@ -639,6 +754,170 @@ function ReviewScreen({ app, entry, targetPath, error }) {
|
|
|
639
754
|
});
|
|
640
755
|
}
|
|
641
756
|
//#endregion
|
|
757
|
+
//#region src/tui/cursor.ts
|
|
758
|
+
const cp = (s) => [...s];
|
|
759
|
+
const isSpace = (c) => c !== void 0 && /\s/.test(c);
|
|
760
|
+
function build(chars, pos) {
|
|
761
|
+
return {
|
|
762
|
+
text: chars.join(""),
|
|
763
|
+
pos: Math.max(0, Math.min(pos, chars.length))
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/** Seed a state with the cursor after the last character — the focus convention. */
|
|
767
|
+
function atEnd(text) {
|
|
768
|
+
return {
|
|
769
|
+
text,
|
|
770
|
+
pos: cp(text).length
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function insert(s, chars) {
|
|
774
|
+
const a = cp(s.text);
|
|
775
|
+
const ins = cp(chars);
|
|
776
|
+
a.splice(s.pos, 0, ...ins);
|
|
777
|
+
return build(a, s.pos + ins.length);
|
|
778
|
+
}
|
|
779
|
+
function backspace(s) {
|
|
780
|
+
if (s.pos <= 0) return s;
|
|
781
|
+
const a = cp(s.text);
|
|
782
|
+
a.splice(s.pos - 1, 1);
|
|
783
|
+
return build(a, s.pos - 1);
|
|
784
|
+
}
|
|
785
|
+
function forwardDelete(s) {
|
|
786
|
+
const a = cp(s.text);
|
|
787
|
+
if (s.pos >= a.length) return s;
|
|
788
|
+
a.splice(s.pos, 1);
|
|
789
|
+
return build(a, s.pos);
|
|
790
|
+
}
|
|
791
|
+
function left(s) {
|
|
792
|
+
return s.pos <= 0 ? s : {
|
|
793
|
+
text: s.text,
|
|
794
|
+
pos: s.pos - 1
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
function right(s) {
|
|
798
|
+
return s.pos >= cp(s.text).length ? s : {
|
|
799
|
+
text: s.text,
|
|
800
|
+
pos: s.pos + 1
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function home(s) {
|
|
804
|
+
return s.pos === 0 ? s : {
|
|
805
|
+
text: s.text,
|
|
806
|
+
pos: 0
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function end(s) {
|
|
810
|
+
const len = cp(s.text).length;
|
|
811
|
+
return s.pos === len ? s : {
|
|
812
|
+
text: s.text,
|
|
813
|
+
pos: len
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Start of the word before `pos`: skip the run of whitespace, then the run of
|
|
818
|
+
* non-whitespace. Mirrors readline's `backward-kill-word`, and reproduces the
|
|
819
|
+
* v0.2 Phase A `deleteWordBack` semantics when pos is at end-of-string.
|
|
820
|
+
*/
|
|
821
|
+
function wordStartBefore(a, pos) {
|
|
822
|
+
let i = pos;
|
|
823
|
+
while (i > 0 && isSpace(a[i - 1])) i--;
|
|
824
|
+
while (i > 0 && !isSpace(a[i - 1])) i--;
|
|
825
|
+
return i;
|
|
826
|
+
}
|
|
827
|
+
/** End of the word after `pos`: skip whitespace, then non-whitespace. */
|
|
828
|
+
function wordEndAfter(a, pos) {
|
|
829
|
+
let i = pos;
|
|
830
|
+
while (i < a.length && isSpace(a[i])) i++;
|
|
831
|
+
while (i < a.length && !isSpace(a[i])) i++;
|
|
832
|
+
return i;
|
|
833
|
+
}
|
|
834
|
+
function wordLeft(s) {
|
|
835
|
+
const start = wordStartBefore(cp(s.text), s.pos);
|
|
836
|
+
return start === s.pos ? s : {
|
|
837
|
+
text: s.text,
|
|
838
|
+
pos: start
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function wordRight(s) {
|
|
842
|
+
const stop = wordEndAfter(cp(s.text), s.pos);
|
|
843
|
+
return stop === s.pos ? s : {
|
|
844
|
+
text: s.text,
|
|
845
|
+
pos: stop
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function deleteWordBack(s) {
|
|
849
|
+
const a = cp(s.text);
|
|
850
|
+
const start = wordStartBefore(a, s.pos);
|
|
851
|
+
if (start === s.pos) return s;
|
|
852
|
+
a.splice(start, s.pos - start);
|
|
853
|
+
return build(a, start);
|
|
854
|
+
}
|
|
855
|
+
function killToStart(s) {
|
|
856
|
+
if (s.pos <= 0) return s;
|
|
857
|
+
return build(cp(s.text).slice(s.pos), 0);
|
|
858
|
+
}
|
|
859
|
+
function killToEnd(s) {
|
|
860
|
+
const a = cp(s.text);
|
|
861
|
+
if (s.pos >= a.length) return s;
|
|
862
|
+
return build(a.slice(0, s.pos), s.pos);
|
|
863
|
+
}
|
|
864
|
+
const same = (a, b) => a.text === b.text && a.pos === b.pos;
|
|
865
|
+
function route(s, input, key, opts) {
|
|
866
|
+
if (key.leftArrow) return key.meta ? wordLeft(s) : left(s);
|
|
867
|
+
if (key.rightArrow) return key.meta ? wordRight(s) : right(s);
|
|
868
|
+
if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") return deleteWordBack(s);
|
|
869
|
+
if (key.backspace || key.delete) return backspace(s);
|
|
870
|
+
if (key.ctrl && input === "u") return killToStart(s);
|
|
871
|
+
if (key.ctrl && input === "k") return killToEnd(s);
|
|
872
|
+
if (key.ctrl && input === "d") return forwardDelete(s);
|
|
873
|
+
if (key.ctrl && input === "a") return home(s);
|
|
874
|
+
if (opts.lineKeys && key.ctrl && input === "e") return end(s);
|
|
875
|
+
if (input && !key.ctrl && !key.meta) return insert(s, input);
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Route a keypress to an op. Returns null when the key is not a text-editing
|
|
880
|
+
* key, OR when the op would leave the state unchanged — callers rely on that
|
|
881
|
+
* to keep their own fallback branches reachable (see AddEntryForm's
|
|
882
|
+
* empty-step-line backspace).
|
|
883
|
+
*/
|
|
884
|
+
function applyTextKey(s, input, key, opts) {
|
|
885
|
+
const next = route(s, input, key, opts);
|
|
886
|
+
if (next === null) return null;
|
|
887
|
+
return same(next, s) ? null : next;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Rows to give the windowed result list, sized to the terminal height so the
|
|
891
|
+
* whole frame fits the viewport. Ink can only redraw in place while the frame
|
|
892
|
+
* stays within the terminal; an oversized frame duplicates on every keypress.
|
|
893
|
+
*/
|
|
894
|
+
function visibleListHeight(terminalRows) {
|
|
895
|
+
return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Integer widths for the two side-by-side panes. They MUST sum to the exact
|
|
899
|
+
* terminal width: two `width="50%"` siblings each round up on an odd-width
|
|
900
|
+
* terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
|
|
901
|
+
* then soft-wraps the full-width rows, Ink miscounts the frame height, and its
|
|
902
|
+
* redraw leaves stale lines stacked on every keystroke.
|
|
903
|
+
*/
|
|
904
|
+
function columnWidths(terminalCols) {
|
|
905
|
+
const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
|
|
906
|
+
const left = Math.floor(cols / 2);
|
|
907
|
+
return {
|
|
908
|
+
left,
|
|
909
|
+
right: cols - left
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/** Columns available to the search query, after "search: " and the filter label. */
|
|
913
|
+
function searchFieldWidth(terminalCols, filterLabelLength) {
|
|
914
|
+
return Math.max(8, (terminalCols && terminalCols > 0 ? terminalCols : 80) - 8 - filterLabelLength - 1);
|
|
915
|
+
}
|
|
916
|
+
/** Columns available to a form field, after its 8-column label plus one safety column. */
|
|
917
|
+
function formFieldWidth(terminalCols) {
|
|
918
|
+
return Math.max(8, (terminalCols && terminalCols > 0 ? terminalCols : 80) - 8 - 1);
|
|
919
|
+
}
|
|
920
|
+
//#endregion
|
|
642
921
|
//#region src/tui/useAddForm.ts
|
|
643
922
|
const emptyDraft = {
|
|
644
923
|
app: "",
|
|
@@ -656,6 +935,20 @@ const emptyDraft = {
|
|
|
656
935
|
function parseTags(raw) {
|
|
657
936
|
return raw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
658
937
|
}
|
|
938
|
+
/** Move the step at `from` to position `to`, returning a NEW array. No-op if out of range or from===to. */
|
|
939
|
+
function moveStep(steps, from, to) {
|
|
940
|
+
if (from < 0 || from >= steps.length || to < 0 || to >= steps.length || from === to) return steps;
|
|
941
|
+
const next = [...steps];
|
|
942
|
+
const [moved] = next.splice(from, 1);
|
|
943
|
+
if (moved === void 0) return steps;
|
|
944
|
+
next.splice(to, 0, moved);
|
|
945
|
+
return next;
|
|
946
|
+
}
|
|
947
|
+
/** Remove the step at `i`, returning a NEW array. No-op if out of range. */
|
|
948
|
+
function deleteStep(steps, i) {
|
|
949
|
+
if (i < 0 || i >= steps.length) return steps;
|
|
950
|
+
return steps.filter((_, idx) => idx !== i);
|
|
951
|
+
}
|
|
659
952
|
function resolvedApp(d) {
|
|
660
953
|
return (d.creatingApp ? d.newApp : d.app).trim();
|
|
661
954
|
}
|
|
@@ -719,6 +1012,23 @@ function useAddForm(initial = {}) {
|
|
|
719
1012
|
setDraft
|
|
720
1013
|
};
|
|
721
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* The text currently being edited for a given focus index. Chooser fields
|
|
1017
|
+
* (0 when not creating an app, 1) have no text and return "".
|
|
1018
|
+
* Used to place the cursor at end-of-value whenever focus changes.
|
|
1019
|
+
*/
|
|
1020
|
+
function fieldValue(d, focused) {
|
|
1021
|
+
if (focused === 0) return d.creatingApp ? d.newApp : "";
|
|
1022
|
+
if (focused === 2) return d.action;
|
|
1023
|
+
if (focused === 3) {
|
|
1024
|
+
if (d.type === "command") return d.command;
|
|
1025
|
+
if (d.type === "shortcut") return d.keys;
|
|
1026
|
+
return d.stepLine;
|
|
1027
|
+
}
|
|
1028
|
+
if (focused === 4) return d.tags;
|
|
1029
|
+
if (focused === 5) return d.notes;
|
|
1030
|
+
return "";
|
|
1031
|
+
}
|
|
722
1032
|
//#endregion
|
|
723
1033
|
//#region src/tui/AddEntryForm.tsx
|
|
724
1034
|
const TYPES = [
|
|
@@ -727,29 +1037,125 @@ const TYPES = [
|
|
|
727
1037
|
"recipe"
|
|
728
1038
|
];
|
|
729
1039
|
const LAST_FIELD = 5;
|
|
730
|
-
function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial,
|
|
1040
|
+
function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
|
|
731
1041
|
const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
|
|
732
|
-
const
|
|
733
|
-
const [
|
|
1042
|
+
const { stdout } = useStdout();
|
|
1043
|
+
const [focused, setFocused] = useState(initialFocus ?? 0);
|
|
1044
|
+
const [pos, setPos] = useState(() => [...fieldValue(draft, initialFocus ?? 0)].length);
|
|
1045
|
+
const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
|
|
1046
|
+
const [grabbed, setGrabbed] = useState(false);
|
|
1047
|
+
const [editingStep, setEditingStep] = useState(null);
|
|
1048
|
+
const [editBuffer, setEditBuffer] = useState("");
|
|
1049
|
+
const [appIndex, setAppIndex] = useState(() => {
|
|
1050
|
+
const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
|
|
1051
|
+
return i >= 0 ? i : 0;
|
|
1052
|
+
});
|
|
734
1053
|
const [screen, setScreen] = useState("form");
|
|
735
1054
|
const [hint, setHint] = useState("");
|
|
736
1055
|
const [writeError, setWriteError] = useState("");
|
|
1056
|
+
const draftRef = useRef(draft);
|
|
1057
|
+
draftRef.current = draft;
|
|
1058
|
+
const posRef = useRef(pos);
|
|
1059
|
+
posRef.current = pos;
|
|
1060
|
+
const focusedRef = useRef(focused);
|
|
1061
|
+
focusedRef.current = focused;
|
|
1062
|
+
const editingStepRef = useRef(editingStep);
|
|
1063
|
+
editingStepRef.current = editingStep;
|
|
1064
|
+
const editBufferRef = useRef(editBuffer);
|
|
1065
|
+
editBufferRef.current = editBuffer;
|
|
1066
|
+
const stepCursorRef = useRef(stepCursor);
|
|
1067
|
+
stepCursorRef.current = stepCursor;
|
|
1068
|
+
const grabbedRef = useRef(grabbed);
|
|
1069
|
+
grabbedRef.current = grabbed;
|
|
1070
|
+
/**
|
|
1071
|
+
* Change focus AND place the cursor at the end of the newly-focused field.
|
|
1072
|
+
* Done here rather than in a useEffect: an effect runs after render, leaving
|
|
1073
|
+
* one frame drawn with the previous field's pos.
|
|
1074
|
+
*
|
|
1075
|
+
* Also synchronously clears any open step edit OR grab. ⌃N/⌃P are handled
|
|
1076
|
+
* above the field-3 block, so they fire even while `editingStepRef.current`
|
|
1077
|
+
* is set or `grabbedRef.current` is true — if a SECOND keystroke (e.g. esc)
|
|
1078
|
+
* arrives in the same batch right after, it must see the edit/grab as
|
|
1079
|
+
* already closed, not wait for the cleanup effect below (which only runs
|
|
1080
|
+
* after the batch is committed and rendered, i.e. too late for that second
|
|
1081
|
+
* keystroke).
|
|
1082
|
+
*/
|
|
1083
|
+
const focusField = (f) => {
|
|
1084
|
+
focusedRef.current = f;
|
|
1085
|
+
setFocused(f);
|
|
1086
|
+
const next = [...fieldValue(draftRef.current, f)].length;
|
|
1087
|
+
posRef.current = next;
|
|
1088
|
+
setPos(next);
|
|
1089
|
+
editingStepRef.current = null;
|
|
1090
|
+
setEditingStep(null);
|
|
1091
|
+
editBufferRef.current = "";
|
|
1092
|
+
setEditBuffer("");
|
|
1093
|
+
grabbedRef.current = false;
|
|
1094
|
+
setGrabbed(false);
|
|
1095
|
+
stepCursorRef.current = draftRef.current.steps.length;
|
|
1096
|
+
setStepCursor(draftRef.current.steps.length);
|
|
1097
|
+
};
|
|
1098
|
+
/** Enter/exit step-edit mode, keeping the ref in sync for same-batch reads. */
|
|
1099
|
+
const setEditing = (step) => {
|
|
1100
|
+
editingStepRef.current = step;
|
|
1101
|
+
setEditingStep(step);
|
|
1102
|
+
};
|
|
1103
|
+
/** Move the step cursor, keeping the ref in sync for same-batch reads. */
|
|
1104
|
+
const setStep = (cursor) => {
|
|
1105
|
+
stepCursorRef.current = cursor;
|
|
1106
|
+
setStepCursor(cursor);
|
|
1107
|
+
};
|
|
1108
|
+
/** Toggle grab, keeping the ref in sync for same-batch reads. */
|
|
1109
|
+
const setGrab = (g) => {
|
|
1110
|
+
grabbedRef.current = g;
|
|
1111
|
+
setGrabbed(g);
|
|
1112
|
+
};
|
|
1113
|
+
useEffect(() => {
|
|
1114
|
+
if (!(focused === 3 && draft.type === "recipe")) {
|
|
1115
|
+
grabbedRef.current = false;
|
|
1116
|
+
setGrabbed(false);
|
|
1117
|
+
stepCursorRef.current = draft.steps.length;
|
|
1118
|
+
setStepCursor(draft.steps.length);
|
|
1119
|
+
editingStepRef.current = null;
|
|
1120
|
+
setEditingStep(null);
|
|
1121
|
+
editBufferRef.current = "";
|
|
1122
|
+
setEditBuffer("");
|
|
1123
|
+
}
|
|
1124
|
+
}, [
|
|
1125
|
+
focused,
|
|
1126
|
+
draft.type,
|
|
1127
|
+
draft.steps.length
|
|
1128
|
+
]);
|
|
737
1129
|
const appChoices = [...apps, "Create new app…"];
|
|
738
1130
|
function commitAppSelection(index) {
|
|
739
|
-
if (index === apps.length)
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1131
|
+
if (index === apps.length) {
|
|
1132
|
+
draftRef.current = {
|
|
1133
|
+
...draftRef.current,
|
|
1134
|
+
creatingApp: true
|
|
1135
|
+
};
|
|
1136
|
+
update({ creatingApp: true });
|
|
1137
|
+
} else {
|
|
1138
|
+
const app = apps[index] ?? "";
|
|
1139
|
+
draftRef.current = {
|
|
1140
|
+
...draftRef.current,
|
|
1141
|
+
creatingApp: false,
|
|
1142
|
+
app
|
|
1143
|
+
};
|
|
1144
|
+
update({
|
|
1145
|
+
creatingApp: false,
|
|
1146
|
+
app
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
744
1149
|
}
|
|
745
1150
|
function goReview() {
|
|
746
|
-
const d = flushStep(
|
|
1151
|
+
const d = flushStep(draftRef.current);
|
|
747
1152
|
const v = validateDraft(d);
|
|
748
1153
|
if (v) {
|
|
749
1154
|
setHint(v);
|
|
750
1155
|
return;
|
|
751
1156
|
}
|
|
752
1157
|
setHint("");
|
|
1158
|
+
draftRef.current = d;
|
|
753
1159
|
setDraft(d);
|
|
754
1160
|
setScreen("review");
|
|
755
1161
|
}
|
|
@@ -767,10 +1173,20 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
767
1173
|
}
|
|
768
1174
|
return;
|
|
769
1175
|
}
|
|
1176
|
+
if (grabbedRef.current && key.escape) return setGrab(false);
|
|
1177
|
+
if (editingStepRef.current !== null && key.escape) {
|
|
1178
|
+
setEditing(null);
|
|
1179
|
+
editBufferRef.current = "";
|
|
1180
|
+
setEditBuffer("");
|
|
1181
|
+
const appendEndPos = [...draftRef.current.stepLine].length;
|
|
1182
|
+
posRef.current = appendEndPos;
|
|
1183
|
+
setPos(appendEndPos);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
770
1186
|
if (key.escape) return onCancel();
|
|
771
|
-
if (key.ctrl && input === "n") return
|
|
772
|
-
if (key.ctrl && input === "p") return
|
|
773
|
-
if (
|
|
1187
|
+
if (key.ctrl && input === "n") return focusField(Math.min(focusedRef.current + 1, LAST_FIELD));
|
|
1188
|
+
if (key.ctrl && input === "p") return focusField(Math.max(focusedRef.current - 1, 0));
|
|
1189
|
+
if (focusedRef.current === 0) {
|
|
774
1190
|
if (key.upArrow) {
|
|
775
1191
|
const next = Math.max(appIndex - 1, 0);
|
|
776
1192
|
setAppIndex(next);
|
|
@@ -781,42 +1197,187 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
781
1197
|
setAppIndex(next);
|
|
782
1198
|
return commitAppSelection(next);
|
|
783
1199
|
}
|
|
784
|
-
if (
|
|
785
|
-
if (key.return) return
|
|
786
|
-
|
|
787
|
-
|
|
1200
|
+
if (draftRef.current.creatingApp) {
|
|
1201
|
+
if (key.return) return focusField(1);
|
|
1202
|
+
const nextApp = applyTextKey({
|
|
1203
|
+
text: draftRef.current.newApp,
|
|
1204
|
+
pos: posRef.current
|
|
1205
|
+
}, input, key, { lineKeys: true });
|
|
1206
|
+
if (nextApp) {
|
|
1207
|
+
draftRef.current = {
|
|
1208
|
+
...draftRef.current,
|
|
1209
|
+
newApp: nextApp.text
|
|
1210
|
+
};
|
|
1211
|
+
update({ newApp: nextApp.text });
|
|
1212
|
+
posRef.current = nextApp.pos;
|
|
1213
|
+
setPos(nextApp.pos);
|
|
1214
|
+
}
|
|
788
1215
|
return;
|
|
789
1216
|
}
|
|
790
|
-
if (key.return) return
|
|
1217
|
+
if (key.return) return focusField(1);
|
|
791
1218
|
return;
|
|
792
1219
|
}
|
|
793
|
-
if (
|
|
1220
|
+
if (focusedRef.current === 1) {
|
|
794
1221
|
if (key.leftArrow || key.rightArrow || input === " ") {
|
|
795
|
-
const idx = TYPES.indexOf(
|
|
796
|
-
|
|
1222
|
+
const idx = TYPES.indexOf(draftRef.current.type);
|
|
1223
|
+
const nextType = TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] ?? "shortcut";
|
|
1224
|
+
draftRef.current = {
|
|
1225
|
+
...draftRef.current,
|
|
1226
|
+
type: nextType
|
|
1227
|
+
};
|
|
1228
|
+
return update({ type: nextType });
|
|
797
1229
|
}
|
|
798
|
-
if (key.return) return
|
|
1230
|
+
if (key.return) return focusField(2);
|
|
799
1231
|
return;
|
|
800
1232
|
}
|
|
801
|
-
if (
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1233
|
+
if (focusedRef.current === 3 && draftRef.current.type === "recipe") {
|
|
1234
|
+
const steps = draftRef.current.steps;
|
|
1235
|
+
const onAppendLine = stepCursorRef.current >= steps.length;
|
|
1236
|
+
if (grabbedRef.current) {
|
|
1237
|
+
if (key.upArrow && stepCursorRef.current > 0) {
|
|
1238
|
+
const nextSteps = moveStep(steps, stepCursorRef.current, stepCursorRef.current - 1);
|
|
1239
|
+
draftRef.current = {
|
|
1240
|
+
...draftRef.current,
|
|
1241
|
+
steps: nextSteps
|
|
1242
|
+
};
|
|
1243
|
+
update({ steps: nextSteps });
|
|
1244
|
+
return setStep(stepCursorRef.current - 1);
|
|
1245
|
+
}
|
|
1246
|
+
if (key.downArrow && stepCursorRef.current < steps.length - 1) {
|
|
1247
|
+
const nextSteps = moveStep(steps, stepCursorRef.current, stepCursorRef.current + 1);
|
|
1248
|
+
draftRef.current = {
|
|
1249
|
+
...draftRef.current,
|
|
1250
|
+
steps: nextSteps
|
|
1251
|
+
};
|
|
1252
|
+
update({ steps: nextSteps });
|
|
1253
|
+
return setStep(stepCursorRef.current + 1);
|
|
1254
|
+
}
|
|
1255
|
+
if (key.return || input === " " || key.escape) return setGrab(false);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (editingStepRef.current !== null) {
|
|
1259
|
+
const idx = editingStepRef.current;
|
|
1260
|
+
if (key.return) {
|
|
1261
|
+
const trimmed = editBufferRef.current.trim();
|
|
1262
|
+
if (trimmed) {
|
|
1263
|
+
const next = [...steps];
|
|
1264
|
+
next[idx] = trimmed;
|
|
1265
|
+
draftRef.current = {
|
|
1266
|
+
...draftRef.current,
|
|
1267
|
+
steps: next
|
|
1268
|
+
};
|
|
1269
|
+
update({ steps: next });
|
|
1270
|
+
}
|
|
1271
|
+
setEditing(null);
|
|
1272
|
+
editBufferRef.current = "";
|
|
1273
|
+
setEditBuffer("");
|
|
1274
|
+
const appendEndPos = [...draftRef.current.stepLine].length;
|
|
1275
|
+
posRef.current = appendEndPos;
|
|
1276
|
+
setPos(appendEndPos);
|
|
1277
|
+
setStep(steps.length);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const nextEdit = applyTextKey({
|
|
1281
|
+
text: editBufferRef.current,
|
|
1282
|
+
pos: posRef.current
|
|
1283
|
+
}, input, key, { lineKeys: true });
|
|
1284
|
+
if (nextEdit) {
|
|
1285
|
+
editBufferRef.current = nextEdit.text;
|
|
1286
|
+
setEditBuffer(nextEdit.text);
|
|
1287
|
+
posRef.current = nextEdit.pos;
|
|
1288
|
+
setPos(nextEdit.pos);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (onAppendLine) {
|
|
1294
|
+
if ((key.backspace || key.delete) && !draftRef.current.stepLine) {
|
|
1295
|
+
if (steps.length) return setStep(steps.length - 1);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (key.upArrow && !draftRef.current.stepLine) {
|
|
1299
|
+
if (steps.length) return setStep(steps.length - 1);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (key.return) {
|
|
1303
|
+
const trimmed = draftRef.current.stepLine.trim();
|
|
1304
|
+
if (trimmed) {
|
|
1305
|
+
const nextSteps = [...steps, trimmed];
|
|
1306
|
+
draftRef.current = {
|
|
1307
|
+
...draftRef.current,
|
|
1308
|
+
steps: nextSteps,
|
|
1309
|
+
stepLine: ""
|
|
1310
|
+
};
|
|
1311
|
+
update({
|
|
1312
|
+
steps: nextSteps,
|
|
1313
|
+
stepLine: ""
|
|
1314
|
+
});
|
|
1315
|
+
setStep(steps.length + 1);
|
|
1316
|
+
posRef.current = 0;
|
|
1317
|
+
setPos(0);
|
|
1318
|
+
}
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const nextLine = applyTextKey({
|
|
1322
|
+
text: draftRef.current.stepLine,
|
|
1323
|
+
pos: posRef.current
|
|
1324
|
+
}, input, key, { lineKeys: true });
|
|
1325
|
+
if (nextLine) {
|
|
1326
|
+
draftRef.current = {
|
|
1327
|
+
...draftRef.current,
|
|
1328
|
+
stepLine: nextLine.text
|
|
1329
|
+
};
|
|
1330
|
+
update({ stepLine: nextLine.text });
|
|
1331
|
+
posRef.current = nextLine.pos;
|
|
1332
|
+
setPos(nextLine.pos);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (key.upArrow) {
|
|
1336
|
+
if (steps.length) return setStep(steps.length - 1);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (key.ctrl && input === "e") {
|
|
1342
|
+
const target = steps[stepCursorRef.current];
|
|
1343
|
+
if (target === void 0) return;
|
|
1344
|
+
setEditing(stepCursorRef.current);
|
|
1345
|
+
editBufferRef.current = target;
|
|
1346
|
+
setEditBuffer(target);
|
|
1347
|
+
const endPos = [...target].length;
|
|
1348
|
+
posRef.current = endPos;
|
|
1349
|
+
setPos(endPos);
|
|
807
1350
|
return;
|
|
808
1351
|
}
|
|
1352
|
+
if (key.upArrow) return setStep(Math.max(0, stepCursorRef.current - 1));
|
|
1353
|
+
if (key.downArrow) return setStep(stepCursorRef.current + 1);
|
|
1354
|
+
if (key.return || input === " ") return setGrab(true);
|
|
809
1355
|
if (key.backspace || key.delete) {
|
|
810
|
-
|
|
811
|
-
|
|
1356
|
+
const next = deleteStep(steps, stepCursorRef.current);
|
|
1357
|
+
draftRef.current = {
|
|
1358
|
+
...draftRef.current,
|
|
1359
|
+
steps: next
|
|
1360
|
+
};
|
|
1361
|
+
update({ steps: next });
|
|
1362
|
+
return setStep(Math.min(stepCursorRef.current, next.length));
|
|
812
1363
|
}
|
|
813
|
-
if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
|
|
814
1364
|
return;
|
|
815
1365
|
}
|
|
816
1366
|
if (key.return) return goReview();
|
|
817
|
-
const fieldKey =
|
|
818
|
-
|
|
819
|
-
|
|
1367
|
+
const fieldKey = focusedRef.current === 2 ? "action" : focusedRef.current === 3 ? draftRef.current.type === "command" ? "command" : "keys" : focusedRef.current === 4 ? "tags" : "notes";
|
|
1368
|
+
const nextText = applyTextKey({
|
|
1369
|
+
text: draftRef.current[fieldKey],
|
|
1370
|
+
pos: posRef.current
|
|
1371
|
+
}, input, key, { lineKeys: true });
|
|
1372
|
+
if (nextText) {
|
|
1373
|
+
draftRef.current = {
|
|
1374
|
+
...draftRef.current,
|
|
1375
|
+
[fieldKey]: nextText.text
|
|
1376
|
+
};
|
|
1377
|
+
update({ [fieldKey]: nextText.text });
|
|
1378
|
+
posRef.current = nextText.pos;
|
|
1379
|
+
setPos(nextText.pos);
|
|
1380
|
+
}
|
|
820
1381
|
});
|
|
821
1382
|
const enterHint = focused <= 1 ? "next" : focused === 3 && draft.type === "recipe" ? "add step" : "review";
|
|
822
1383
|
if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
|
|
@@ -839,8 +1400,13 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
839
1400
|
apps,
|
|
840
1401
|
appIndex,
|
|
841
1402
|
focused,
|
|
1403
|
+
pos,
|
|
1404
|
+
width: formFieldWidth(stdout?.columns),
|
|
842
1405
|
existingTags,
|
|
843
|
-
|
|
1406
|
+
stepCursor,
|
|
1407
|
+
grabbed,
|
|
1408
|
+
editingStep,
|
|
1409
|
+
editBuffer
|
|
844
1410
|
})
|
|
845
1411
|
}),
|
|
846
1412
|
/* @__PURE__ */ jsxs(Box, {
|
|
@@ -1036,6 +1602,9 @@ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
|
|
|
1036
1602
|
}
|
|
1037
1603
|
//#endregion
|
|
1038
1604
|
//#region src/tui/Footer.tsx
|
|
1605
|
+
function hintLine(filterActive, resultCount) {
|
|
1606
|
+
return `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} (${resultCount})`;
|
|
1607
|
+
}
|
|
1039
1608
|
function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
|
|
1040
1609
|
if (confirm) return /* @__PURE__ */ jsxs(Box, {
|
|
1041
1610
|
marginTop: 1,
|
|
@@ -1056,7 +1625,7 @@ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false
|
|
|
1056
1625
|
children: [/* @__PURE__ */ jsx(Text, {
|
|
1057
1626
|
color: "gray",
|
|
1058
1627
|
wrap: "truncate-end",
|
|
1059
|
-
children:
|
|
1628
|
+
children: hintLine(filterActive, resultCount)
|
|
1060
1629
|
}), flash ? /* @__PURE__ */ jsx(Text, {
|
|
1061
1630
|
color: "green",
|
|
1062
1631
|
wrap: "truncate-end",
|
|
@@ -1162,22 +1731,20 @@ function ResultList({ results, selected, query, height = 12, width = "50%", favo
|
|
|
1162
1731
|
}
|
|
1163
1732
|
//#endregion
|
|
1164
1733
|
//#region src/tui/SearchInput.tsx
|
|
1165
|
-
function SearchInput({ query, filterLabel }) {
|
|
1734
|
+
function SearchInput({ query, pos, width, filterLabel }) {
|
|
1166
1735
|
return /* @__PURE__ */ jsxs(Box, {
|
|
1167
1736
|
justifyContent: "space-between",
|
|
1168
1737
|
children: [/* @__PURE__ */ jsxs(Text, {
|
|
1169
1738
|
wrap: "truncate-end",
|
|
1170
|
-
children: [
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
})
|
|
1180
|
-
]
|
|
1739
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
1740
|
+
color: "cyan",
|
|
1741
|
+
children: "search: "
|
|
1742
|
+
}), /* @__PURE__ */ jsx(CursorText, {
|
|
1743
|
+
value: query,
|
|
1744
|
+
pos,
|
|
1745
|
+
width,
|
|
1746
|
+
focused: true
|
|
1747
|
+
})]
|
|
1181
1748
|
}), filterLabel ? /* @__PURE__ */ jsx(Text, {
|
|
1182
1749
|
color: "gray",
|
|
1183
1750
|
wrap: "truncate-end",
|
|
@@ -1186,43 +1753,8 @@ function SearchInput({ query, filterLabel }) {
|
|
|
1186
1753
|
});
|
|
1187
1754
|
}
|
|
1188
1755
|
//#endregion
|
|
1189
|
-
//#region src/tui/input.ts
|
|
1190
|
-
/**
|
|
1191
|
-
* Drop the run of trailing whitespace, then the run of trailing non-whitespace.
|
|
1192
|
-
* Mirrors readline's `unix-word-rubout` / `backward-kill-word` behavior so a
|
|
1193
|
-
* single press eats both the spaces and the word immediately before them.
|
|
1194
|
-
*/
|
|
1195
|
-
function deleteWordBack(query) {
|
|
1196
|
-
const trimmed = query.replace(/\s+$/, "");
|
|
1197
|
-
const lastSpace = trimmed.lastIndexOf(" ");
|
|
1198
|
-
if (lastSpace === -1) return "";
|
|
1199
|
-
return trimmed.slice(0, lastSpace + 1);
|
|
1200
|
-
}
|
|
1201
|
-
/**
|
|
1202
|
-
* Rows to give the windowed result list, sized to the terminal height so the
|
|
1203
|
-
* whole frame fits the viewport. Ink can only redraw in place while the frame
|
|
1204
|
-
* stays within the terminal; an oversized frame duplicates on every keypress.
|
|
1205
|
-
*/
|
|
1206
|
-
function visibleListHeight(terminalRows) {
|
|
1207
|
-
return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
|
|
1208
|
-
}
|
|
1209
|
-
/**
|
|
1210
|
-
* Integer widths for the two side-by-side panes. They MUST sum to the exact
|
|
1211
|
-
* terminal width: two `width="50%"` siblings each round up on an odd-width
|
|
1212
|
-
* terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
|
|
1213
|
-
* then soft-wraps the full-width rows, Ink miscounts the frame height, and its
|
|
1214
|
-
* redraw leaves stale lines stacked on every keystroke.
|
|
1215
|
-
*/
|
|
1216
|
-
function columnWidths(terminalCols) {
|
|
1217
|
-
const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
|
|
1218
|
-
const left = Math.floor(cols / 2);
|
|
1219
|
-
return {
|
|
1220
|
-
left,
|
|
1221
|
-
right: cols - left
|
|
1222
|
-
};
|
|
1223
|
-
}
|
|
1224
|
-
//#endregion
|
|
1225
1756
|
//#region src/tui/App.tsx
|
|
1757
|
+
const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
1226
1758
|
function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
|
|
1227
1759
|
const { exit } = useApp();
|
|
1228
1760
|
const { stdout } = useStdout();
|
|
@@ -1232,9 +1764,12 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1232
1764
|
const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
|
|
1233
1765
|
const [editTarget, setEditTarget] = useState(null);
|
|
1234
1766
|
const [pendingDelete, setPendingDelete] = useState(null);
|
|
1235
|
-
const [query, setQuery] = useState("");
|
|
1767
|
+
const [query, setQuery] = useState(() => atEnd(""));
|
|
1236
1768
|
const [selected, setSelected] = useState(0);
|
|
1237
1769
|
const [flash, setFlash] = useState("");
|
|
1770
|
+
const queryRef = useRef(query);
|
|
1771
|
+
queryRef.current = query;
|
|
1772
|
+
const currentRef = useRef(void 0);
|
|
1238
1773
|
const reload = useCallback(() => {
|
|
1239
1774
|
if (dataDir) setEntries(loadEntries(dataDir).entries);
|
|
1240
1775
|
}, [dataDir]);
|
|
@@ -1247,9 +1782,10 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1247
1782
|
filter,
|
|
1248
1783
|
favorites
|
|
1249
1784
|
]);
|
|
1250
|
-
const results = useMemo(() => search(scoped, query), [scoped, query]);
|
|
1785
|
+
const results = useMemo(() => search(scoped, query.text), [scoped, query.text]);
|
|
1251
1786
|
const sel = results.length ? Math.min(selected, results.length - 1) : 0;
|
|
1252
1787
|
const current = results[sel];
|
|
1788
|
+
currentRef.current = current;
|
|
1253
1789
|
const listHeight = visibleListHeight(stdout?.rows);
|
|
1254
1790
|
const { left, right } = columnWidths(stdout?.columns);
|
|
1255
1791
|
useInput((input, key) => {
|
|
@@ -1270,6 +1806,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1270
1806
|
return;
|
|
1271
1807
|
}
|
|
1272
1808
|
if (!key.return && flash) setFlash("");
|
|
1809
|
+
const curEntry = currentRef.current;
|
|
1273
1810
|
if (key.escape) {
|
|
1274
1811
|
if (filter.type !== "all") {
|
|
1275
1812
|
setFilter({ type: "all" });
|
|
@@ -1283,23 +1820,23 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1283
1820
|
setMode("add");
|
|
1284
1821
|
return;
|
|
1285
1822
|
}
|
|
1286
|
-
if (dataDir &&
|
|
1287
|
-
setEditTarget(
|
|
1823
|
+
if (dataDir && curEntry && key.ctrl && input === "e") {
|
|
1824
|
+
setEditTarget(curEntry);
|
|
1288
1825
|
setMode("edit");
|
|
1289
1826
|
return;
|
|
1290
1827
|
}
|
|
1291
|
-
if (dataDir &&
|
|
1292
|
-
setPendingDelete(
|
|
1828
|
+
if (dataDir && curEntry && key.ctrl && input === "x") {
|
|
1829
|
+
setPendingDelete(curEntry);
|
|
1293
1830
|
return;
|
|
1294
1831
|
}
|
|
1295
1832
|
if (key.ctrl && input === "f") {
|
|
1296
1833
|
setMode("filter");
|
|
1297
1834
|
return;
|
|
1298
1835
|
}
|
|
1299
|
-
if (dataDir &&
|
|
1300
|
-
const next = toggleFavorite(dataDir,
|
|
1836
|
+
if (dataDir && curEntry && key.ctrl && input === "s") {
|
|
1837
|
+
const next = toggleFavorite(dataDir, curEntry.app, curEntry.action);
|
|
1301
1838
|
setFavorites(next);
|
|
1302
|
-
setFlash(next.has(favKey(
|
|
1839
|
+
setFlash(next.has(favKey(curEntry.app, curEntry.action)) ? "★ starred" : "☆ unstarred");
|
|
1303
1840
|
return;
|
|
1304
1841
|
}
|
|
1305
1842
|
if (key.downArrow || key.ctrl && input === "n") {
|
|
@@ -1311,28 +1848,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1311
1848
|
return;
|
|
1312
1849
|
}
|
|
1313
1850
|
if (key.return) {
|
|
1314
|
-
if (
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
|
|
1318
|
-
setQuery(deleteWordBack);
|
|
1319
|
-
setSelected(0);
|
|
1320
|
-
return;
|
|
1321
|
-
}
|
|
1322
|
-
if (key.ctrl && input === "u") {
|
|
1323
|
-
setQuery("");
|
|
1324
|
-
setSelected(0);
|
|
1851
|
+
if (curEntry) setFlash(onCopy(curEntry.keys ?? curEntry.command ?? `${curEntry.app}: ${curEntry.action}`) ? "✓ copied!" : "✗ copy failed");
|
|
1325
1852
|
return;
|
|
1326
1853
|
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1854
|
+
const cur = queryRef.current;
|
|
1855
|
+
const next = applyTextKey(cur, input, key, { lineKeys: false });
|
|
1856
|
+
if (next) {
|
|
1857
|
+
if (next.text !== cur.text) setSelected(0);
|
|
1858
|
+
queryRef.current = next;
|
|
1859
|
+
setQuery(next);
|
|
1330
1860
|
return;
|
|
1331
1861
|
}
|
|
1332
|
-
if (input && !key.ctrl && !key.meta) {
|
|
1333
|
-
setQuery((q) => q + input);
|
|
1334
|
-
setSelected(0);
|
|
1335
|
-
}
|
|
1336
1862
|
}, { isActive: mode === "search" });
|
|
1337
1863
|
const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
|
|
1338
1864
|
if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
|
|
@@ -1364,14 +1890,14 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1364
1890
|
if (mode === "edit" && dataDir && editTarget) return /* @__PURE__ */ jsx(AddEntryForm, {
|
|
1365
1891
|
apps: listApps(dataDir),
|
|
1366
1892
|
existingTags,
|
|
1367
|
-
|
|
1893
|
+
initialFocus: 1,
|
|
1368
1894
|
initial: entryToDraft(editTarget.app, editTarget),
|
|
1369
1895
|
title: `Edit entry — ${editTarget.app}`,
|
|
1370
|
-
resolveTarget: () => ({
|
|
1896
|
+
resolveTarget: (app) => sameApp(app, editTarget.app) ? {
|
|
1371
1897
|
file: editTarget.file,
|
|
1372
1898
|
created: false
|
|
1373
|
-
}),
|
|
1374
|
-
onSubmit: (
|
|
1899
|
+
} : resolveTargetFile(dataDir, app),
|
|
1900
|
+
onSubmit: (app, entry) => sameApp(app, editTarget.app) ? editEntry(dataDir, editTarget.file, editTarget.index, entry, editTarget.action) : moveEntry(dataDir, editTarget.file, editTarget.index, editTarget.action, app, entry),
|
|
1375
1901
|
onComplete: (result) => {
|
|
1376
1902
|
if (result.ok) {
|
|
1377
1903
|
reload();
|
|
@@ -1386,17 +1912,20 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1386
1912
|
setEditTarget(null);
|
|
1387
1913
|
}
|
|
1388
1914
|
});
|
|
1915
|
+
const filterLabelText = filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0;
|
|
1389
1916
|
return /* @__PURE__ */ jsxs(Box, {
|
|
1390
1917
|
flexDirection: "column",
|
|
1391
1918
|
children: [
|
|
1392
1919
|
/* @__PURE__ */ jsx(SearchInput, {
|
|
1393
|
-
query,
|
|
1394
|
-
|
|
1920
|
+
query: query.text,
|
|
1921
|
+
pos: query.pos,
|
|
1922
|
+
width: searchFieldWidth(stdout?.columns, filterLabelText?.length ?? 0),
|
|
1923
|
+
filterLabel: filterLabelText
|
|
1395
1924
|
}),
|
|
1396
1925
|
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
|
|
1397
1926
|
results,
|
|
1398
1927
|
selected: sel,
|
|
1399
|
-
query,
|
|
1928
|
+
query: query.text,
|
|
1400
1929
|
height: listHeight,
|
|
1401
1930
|
width: left,
|
|
1402
1931
|
favorites,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arthony/keybook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"test:watch": "vitest",
|
|
66
66
|
"typecheck": "tsc --noEmit",
|
|
67
67
|
"lint": "biome check .",
|
|
68
|
-
"format": "biome format --write ."
|
|
68
|
+
"format": "biome format --write .",
|
|
69
|
+
"bump-tap": "./scripts/bump-tap.sh"
|
|
69
70
|
}
|
|
70
71
|
}
|