@arthony/keybook 0.7.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 +624 -187
- package/package.json +1 -1
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, useEffect, 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$/;
|
|
@@ -428,38 +428,106 @@ function runAdd(dir, draft) {
|
|
|
428
428
|
};
|
|
429
429
|
}
|
|
430
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
|
|
431
483
|
//#region src/tui/StepsBuilder.tsx
|
|
432
|
-
function StepsBuilder({ steps, line, active, cursor, grabbed = false }) {
|
|
484
|
+
function StepsBuilder({ steps, line, active, cursor, grabbed = false, pos, width, editingIndex, editBuffer }) {
|
|
433
485
|
const cur = cursor ?? steps.length;
|
|
434
486
|
const onAppendLine = cur >= steps.length;
|
|
435
|
-
const
|
|
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";
|
|
436
490
|
return /* @__PURE__ */ jsxs(Box, {
|
|
437
491
|
flexDirection: "column",
|
|
438
492
|
children: [
|
|
439
|
-
steps.map((s, i) => {
|
|
440
|
-
|
|
441
|
-
|
|
493
|
+
steps.map((s, i) => i === editingIndex ? /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Text, {
|
|
494
|
+
color: "cyan",
|
|
495
|
+
children: [
|
|
496
|
+
" ",
|
|
442
497
|
i + 1,
|
|
443
|
-
".
|
|
444
|
-
|
|
445
|
-
]
|
|
446
|
-
}),
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 ? "⇅ " : "> " : " ",
|
|
508
|
+
i + 1,
|
|
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
|
+
})] }),
|
|
463
531
|
active ? /* @__PURE__ */ jsx(Text, {
|
|
464
532
|
color: "gray",
|
|
465
533
|
children: hint
|
|
@@ -474,31 +542,32 @@ const TYPES$1 = [
|
|
|
474
542
|
"command",
|
|
475
543
|
"recipe"
|
|
476
544
|
];
|
|
477
|
-
function Field({ label, value, focused }) {
|
|
478
|
-
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor, grabbed }) {
|
|
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 }) {
|
|
491
557
|
const appChoices = [...apps, "Create new app…"];
|
|
558
|
+
const keysPreview = draft.keys.trim() ? ` → ${normalizeKeys(draft.keys)}` : "";
|
|
492
559
|
return /* @__PURE__ */ jsxs(Box, {
|
|
493
560
|
flexDirection: "column",
|
|
494
561
|
children: [
|
|
495
562
|
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
496
563
|
color: focused === 0 ? "cyan" : "gray",
|
|
497
564
|
children: "App".padEnd(8)
|
|
498
|
-
}), draft.creatingApp ? /* @__PURE__ */
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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 ? " (↑/↓)" : ""] })] }),
|
|
502
571
|
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
503
572
|
color: focused === 1 ? "cyan" : "gray",
|
|
504
573
|
children: "Type".padEnd(8)
|
|
@@ -506,27 +575,38 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor,
|
|
|
506
575
|
/* @__PURE__ */ jsx(Field, {
|
|
507
576
|
label: "Action",
|
|
508
577
|
value: draft.action,
|
|
509
|
-
focused: focused === 2
|
|
578
|
+
focused: focused === 2,
|
|
579
|
+
pos,
|
|
580
|
+
width
|
|
510
581
|
}),
|
|
511
582
|
draft.type === "shortcut" ? /* @__PURE__ */ jsxs(Box, { children: [
|
|
512
583
|
/* @__PURE__ */ jsx(Text, {
|
|
513
584
|
color: focused === 3 ? "cyan" : "gray",
|
|
514
585
|
children: "Keys".padEnd(8)
|
|
515
586
|
}),
|
|
516
|
-
/* @__PURE__ */ jsx(
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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, {
|
|
522
594
|
color: "gray",
|
|
523
|
-
children:
|
|
595
|
+
children: keysPreview
|
|
524
596
|
}) : null
|
|
525
|
-
] }) : draft.type === "command" ? /* @__PURE__ */
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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, {
|
|
530
610
|
flexDirection: "column",
|
|
531
611
|
children: [/* @__PURE__ */ jsx(Text, {
|
|
532
612
|
color: focused === 3 ? "cyan" : "gray",
|
|
@@ -536,13 +616,19 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor,
|
|
|
536
616
|
line: draft.stepLine,
|
|
537
617
|
active: focused === 3,
|
|
538
618
|
cursor: stepCursor,
|
|
539
|
-
grabbed
|
|
619
|
+
grabbed,
|
|
620
|
+
pos,
|
|
621
|
+
width,
|
|
622
|
+
editingIndex: editingStep ?? void 0,
|
|
623
|
+
editBuffer
|
|
540
624
|
})]
|
|
541
625
|
}),
|
|
542
626
|
/* @__PURE__ */ jsx(Field, {
|
|
543
627
|
label: "Tags",
|
|
544
628
|
value: draft.tags,
|
|
545
|
-
focused: focused === 4
|
|
629
|
+
focused: focused === 4,
|
|
630
|
+
pos,
|
|
631
|
+
width
|
|
546
632
|
}),
|
|
547
633
|
focused === 4 && existingTags && existingTags.length > 0 ? /* @__PURE__ */ jsx(Text, {
|
|
548
634
|
color: "gray",
|
|
@@ -551,7 +637,9 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor,
|
|
|
551
637
|
/* @__PURE__ */ jsx(Field, {
|
|
552
638
|
label: "Notes",
|
|
553
639
|
value: draft.notes,
|
|
554
|
-
focused: focused === 5
|
|
640
|
+
focused: focused === 5,
|
|
641
|
+
pos,
|
|
642
|
+
width
|
|
555
643
|
})
|
|
556
644
|
]
|
|
557
645
|
});
|
|
@@ -666,6 +754,170 @@ function ReviewScreen({ app, entry, targetPath, error }) {
|
|
|
666
754
|
});
|
|
667
755
|
}
|
|
668
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
|
|
669
921
|
//#region src/tui/useAddForm.ts
|
|
670
922
|
const emptyDraft = {
|
|
671
923
|
app: "",
|
|
@@ -760,6 +1012,23 @@ function useAddForm(initial = {}) {
|
|
|
760
1012
|
setDraft
|
|
761
1013
|
};
|
|
762
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
|
+
}
|
|
763
1032
|
//#endregion
|
|
764
1033
|
//#region src/tui/AddEntryForm.tsx
|
|
765
1034
|
const TYPES = [
|
|
@@ -770,9 +1039,13 @@ const TYPES = [
|
|
|
770
1039
|
const LAST_FIELD = 5;
|
|
771
1040
|
function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
|
|
772
1041
|
const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
|
|
1042
|
+
const { stdout } = useStdout();
|
|
773
1043
|
const [focused, setFocused] = useState(initialFocus ?? 0);
|
|
1044
|
+
const [pos, setPos] = useState(() => [...fieldValue(draft, initialFocus ?? 0)].length);
|
|
774
1045
|
const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
|
|
775
1046
|
const [grabbed, setGrabbed] = useState(false);
|
|
1047
|
+
const [editingStep, setEditingStep] = useState(null);
|
|
1048
|
+
const [editBuffer, setEditBuffer] = useState("");
|
|
776
1049
|
const [appIndex, setAppIndex] = useState(() => {
|
|
777
1050
|
const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
|
|
778
1051
|
return i >= 0 ? i : 0;
|
|
@@ -780,10 +1053,73 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
780
1053
|
const [screen, setScreen] = useState("form");
|
|
781
1054
|
const [hint, setHint] = useState("");
|
|
782
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
|
+
};
|
|
783
1113
|
useEffect(() => {
|
|
784
1114
|
if (!(focused === 3 && draft.type === "recipe")) {
|
|
1115
|
+
grabbedRef.current = false;
|
|
785
1116
|
setGrabbed(false);
|
|
1117
|
+
stepCursorRef.current = draft.steps.length;
|
|
786
1118
|
setStepCursor(draft.steps.length);
|
|
1119
|
+
editingStepRef.current = null;
|
|
1120
|
+
setEditingStep(null);
|
|
1121
|
+
editBufferRef.current = "";
|
|
1122
|
+
setEditBuffer("");
|
|
787
1123
|
}
|
|
788
1124
|
}, [
|
|
789
1125
|
focused,
|
|
@@ -792,20 +1128,34 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
792
1128
|
]);
|
|
793
1129
|
const appChoices = [...apps, "Create new app…"];
|
|
794
1130
|
function commitAppSelection(index) {
|
|
795
|
-
if (index === apps.length)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
+
}
|
|
800
1149
|
}
|
|
801
1150
|
function goReview() {
|
|
802
|
-
const d = flushStep(
|
|
1151
|
+
const d = flushStep(draftRef.current);
|
|
803
1152
|
const v = validateDraft(d);
|
|
804
1153
|
if (v) {
|
|
805
1154
|
setHint(v);
|
|
806
1155
|
return;
|
|
807
1156
|
}
|
|
808
1157
|
setHint("");
|
|
1158
|
+
draftRef.current = d;
|
|
809
1159
|
setDraft(d);
|
|
810
1160
|
setScreen("review");
|
|
811
1161
|
}
|
|
@@ -823,11 +1173,20 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
823
1173
|
}
|
|
824
1174
|
return;
|
|
825
1175
|
}
|
|
826
|
-
if (
|
|
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
|
+
}
|
|
827
1186
|
if (key.escape) return onCancel();
|
|
828
|
-
if (key.ctrl && input === "n") return
|
|
829
|
-
if (key.ctrl && input === "p") return
|
|
830
|
-
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) {
|
|
831
1190
|
if (key.upArrow) {
|
|
832
1191
|
const next = Math.max(appIndex - 1, 0);
|
|
833
1192
|
setAppIndex(next);
|
|
@@ -838,75 +1197,187 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
838
1197
|
setAppIndex(next);
|
|
839
1198
|
return commitAppSelection(next);
|
|
840
1199
|
}
|
|
841
|
-
if (
|
|
842
|
-
if (key.return) return
|
|
843
|
-
|
|
844
|
-
|
|
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
|
+
}
|
|
845
1215
|
return;
|
|
846
1216
|
}
|
|
847
|
-
if (key.return) return
|
|
1217
|
+
if (key.return) return focusField(1);
|
|
848
1218
|
return;
|
|
849
1219
|
}
|
|
850
|
-
if (
|
|
1220
|
+
if (focusedRef.current === 1) {
|
|
851
1221
|
if (key.leftArrow || key.rightArrow || input === " ") {
|
|
852
|
-
const idx = TYPES.indexOf(
|
|
853
|
-
|
|
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 });
|
|
854
1229
|
}
|
|
855
|
-
if (key.return) return
|
|
1230
|
+
if (key.return) return focusField(2);
|
|
856
1231
|
return;
|
|
857
1232
|
}
|
|
858
|
-
if (
|
|
859
|
-
const steps =
|
|
860
|
-
const onAppendLine =
|
|
861
|
-
if (
|
|
862
|
-
if (key.upArrow &&
|
|
863
|
-
|
|
864
|
-
|
|
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);
|
|
865
1245
|
}
|
|
866
|
-
if (key.downArrow &&
|
|
867
|
-
|
|
868
|
-
|
|
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;
|
|
869
1290
|
}
|
|
870
|
-
if (key.return || input === " " || key.escape) return setGrabbed(false);
|
|
871
1291
|
return;
|
|
872
1292
|
}
|
|
873
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
|
+
}
|
|
874
1302
|
if (key.return) {
|
|
875
|
-
|
|
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
|
+
};
|
|
876
1311
|
update({
|
|
877
|
-
steps:
|
|
1312
|
+
steps: nextSteps,
|
|
878
1313
|
stepLine: ""
|
|
879
1314
|
});
|
|
880
|
-
|
|
1315
|
+
setStep(steps.length + 1);
|
|
1316
|
+
posRef.current = 0;
|
|
1317
|
+
setPos(0);
|
|
881
1318
|
}
|
|
882
1319
|
return;
|
|
883
1320
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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);
|
|
887
1333
|
return;
|
|
888
1334
|
}
|
|
889
1335
|
if (key.upArrow) {
|
|
890
|
-
if (steps.length) return
|
|
1336
|
+
if (steps.length) return setStep(steps.length - 1);
|
|
891
1337
|
return;
|
|
892
1338
|
}
|
|
893
|
-
if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
|
|
894
1339
|
return;
|
|
895
1340
|
}
|
|
896
|
-
if (key.
|
|
897
|
-
|
|
898
|
-
|
|
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);
|
|
1350
|
+
return;
|
|
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);
|
|
899
1355
|
if (key.backspace || key.delete) {
|
|
900
|
-
const next = deleteStep(steps,
|
|
1356
|
+
const next = deleteStep(steps, stepCursorRef.current);
|
|
1357
|
+
draftRef.current = {
|
|
1358
|
+
...draftRef.current,
|
|
1359
|
+
steps: next
|
|
1360
|
+
};
|
|
901
1361
|
update({ steps: next });
|
|
902
|
-
return
|
|
1362
|
+
return setStep(Math.min(stepCursorRef.current, next.length));
|
|
903
1363
|
}
|
|
904
1364
|
return;
|
|
905
1365
|
}
|
|
906
1366
|
if (key.return) return goReview();
|
|
907
|
-
const fieldKey =
|
|
908
|
-
|
|
909
|
-
|
|
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
|
+
}
|
|
910
1381
|
});
|
|
911
1382
|
const enterHint = focused <= 1 ? "next" : focused === 3 && draft.type === "recipe" ? "add step" : "review";
|
|
912
1383
|
if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
|
|
@@ -929,9 +1400,13 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
|
|
|
929
1400
|
apps,
|
|
930
1401
|
appIndex,
|
|
931
1402
|
focused,
|
|
1403
|
+
pos,
|
|
1404
|
+
width: formFieldWidth(stdout?.columns),
|
|
932
1405
|
existingTags,
|
|
933
1406
|
stepCursor,
|
|
934
|
-
grabbed
|
|
1407
|
+
grabbed,
|
|
1408
|
+
editingStep,
|
|
1409
|
+
editBuffer
|
|
935
1410
|
})
|
|
936
1411
|
}),
|
|
937
1412
|
/* @__PURE__ */ jsxs(Box, {
|
|
@@ -1127,6 +1602,9 @@ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
|
|
|
1127
1602
|
}
|
|
1128
1603
|
//#endregion
|
|
1129
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
|
+
}
|
|
1130
1608
|
function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
|
|
1131
1609
|
if (confirm) return /* @__PURE__ */ jsxs(Box, {
|
|
1132
1610
|
marginTop: 1,
|
|
@@ -1147,7 +1625,7 @@ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false
|
|
|
1147
1625
|
children: [/* @__PURE__ */ jsx(Text, {
|
|
1148
1626
|
color: "gray",
|
|
1149
1627
|
wrap: "truncate-end",
|
|
1150
|
-
children:
|
|
1628
|
+
children: hintLine(filterActive, resultCount)
|
|
1151
1629
|
}), flash ? /* @__PURE__ */ jsx(Text, {
|
|
1152
1630
|
color: "green",
|
|
1153
1631
|
wrap: "truncate-end",
|
|
@@ -1253,22 +1731,20 @@ function ResultList({ results, selected, query, height = 12, width = "50%", favo
|
|
|
1253
1731
|
}
|
|
1254
1732
|
//#endregion
|
|
1255
1733
|
//#region src/tui/SearchInput.tsx
|
|
1256
|
-
function SearchInput({ query, filterLabel }) {
|
|
1734
|
+
function SearchInput({ query, pos, width, filterLabel }) {
|
|
1257
1735
|
return /* @__PURE__ */ jsxs(Box, {
|
|
1258
1736
|
justifyContent: "space-between",
|
|
1259
1737
|
children: [/* @__PURE__ */ jsxs(Text, {
|
|
1260
1738
|
wrap: "truncate-end",
|
|
1261
|
-
children: [
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
})
|
|
1271
|
-
]
|
|
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
|
+
})]
|
|
1272
1748
|
}), filterLabel ? /* @__PURE__ */ jsx(Text, {
|
|
1273
1749
|
color: "gray",
|
|
1274
1750
|
wrap: "truncate-end",
|
|
@@ -1277,42 +1753,6 @@ function SearchInput({ query, filterLabel }) {
|
|
|
1277
1753
|
});
|
|
1278
1754
|
}
|
|
1279
1755
|
//#endregion
|
|
1280
|
-
//#region src/tui/input.ts
|
|
1281
|
-
/**
|
|
1282
|
-
* Drop the run of trailing whitespace, then the run of trailing non-whitespace.
|
|
1283
|
-
* Mirrors readline's `unix-word-rubout` / `backward-kill-word` behavior so a
|
|
1284
|
-
* single press eats both the spaces and the word immediately before them.
|
|
1285
|
-
*/
|
|
1286
|
-
function deleteWordBack(query) {
|
|
1287
|
-
const trimmed = query.replace(/\s+$/, "");
|
|
1288
|
-
const lastSpace = trimmed.lastIndexOf(" ");
|
|
1289
|
-
if (lastSpace === -1) return "";
|
|
1290
|
-
return trimmed.slice(0, lastSpace + 1);
|
|
1291
|
-
}
|
|
1292
|
-
/**
|
|
1293
|
-
* Rows to give the windowed result list, sized to the terminal height so the
|
|
1294
|
-
* whole frame fits the viewport. Ink can only redraw in place while the frame
|
|
1295
|
-
* stays within the terminal; an oversized frame duplicates on every keypress.
|
|
1296
|
-
*/
|
|
1297
|
-
function visibleListHeight(terminalRows) {
|
|
1298
|
-
return Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 4);
|
|
1299
|
-
}
|
|
1300
|
-
/**
|
|
1301
|
-
* Integer widths for the two side-by-side panes. They MUST sum to the exact
|
|
1302
|
-
* terminal width: two `width="50%"` siblings each round up on an odd-width
|
|
1303
|
-
* terminal (58 + 58 = 116 at 115 cols), overflowing by a column. The terminal
|
|
1304
|
-
* then soft-wraps the full-width rows, Ink miscounts the frame height, and its
|
|
1305
|
-
* redraw leaves stale lines stacked on every keystroke.
|
|
1306
|
-
*/
|
|
1307
|
-
function columnWidths(terminalCols) {
|
|
1308
|
-
const cols = terminalCols && terminalCols > 0 ? terminalCols : 80;
|
|
1309
|
-
const left = Math.floor(cols / 2);
|
|
1310
|
-
return {
|
|
1311
|
-
left,
|
|
1312
|
-
right: cols - left
|
|
1313
|
-
};
|
|
1314
|
-
}
|
|
1315
|
-
//#endregion
|
|
1316
1756
|
//#region src/tui/App.tsx
|
|
1317
1757
|
const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
1318
1758
|
function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
|
|
@@ -1324,9 +1764,12 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1324
1764
|
const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
|
|
1325
1765
|
const [editTarget, setEditTarget] = useState(null);
|
|
1326
1766
|
const [pendingDelete, setPendingDelete] = useState(null);
|
|
1327
|
-
const [query, setQuery] = useState("");
|
|
1767
|
+
const [query, setQuery] = useState(() => atEnd(""));
|
|
1328
1768
|
const [selected, setSelected] = useState(0);
|
|
1329
1769
|
const [flash, setFlash] = useState("");
|
|
1770
|
+
const queryRef = useRef(query);
|
|
1771
|
+
queryRef.current = query;
|
|
1772
|
+
const currentRef = useRef(void 0);
|
|
1330
1773
|
const reload = useCallback(() => {
|
|
1331
1774
|
if (dataDir) setEntries(loadEntries(dataDir).entries);
|
|
1332
1775
|
}, [dataDir]);
|
|
@@ -1339,9 +1782,10 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1339
1782
|
filter,
|
|
1340
1783
|
favorites
|
|
1341
1784
|
]);
|
|
1342
|
-
const results = useMemo(() => search(scoped, query), [scoped, query]);
|
|
1785
|
+
const results = useMemo(() => search(scoped, query.text), [scoped, query.text]);
|
|
1343
1786
|
const sel = results.length ? Math.min(selected, results.length - 1) : 0;
|
|
1344
1787
|
const current = results[sel];
|
|
1788
|
+
currentRef.current = current;
|
|
1345
1789
|
const listHeight = visibleListHeight(stdout?.rows);
|
|
1346
1790
|
const { left, right } = columnWidths(stdout?.columns);
|
|
1347
1791
|
useInput((input, key) => {
|
|
@@ -1362,6 +1806,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1362
1806
|
return;
|
|
1363
1807
|
}
|
|
1364
1808
|
if (!key.return && flash) setFlash("");
|
|
1809
|
+
const curEntry = currentRef.current;
|
|
1365
1810
|
if (key.escape) {
|
|
1366
1811
|
if (filter.type !== "all") {
|
|
1367
1812
|
setFilter({ type: "all" });
|
|
@@ -1375,23 +1820,23 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1375
1820
|
setMode("add");
|
|
1376
1821
|
return;
|
|
1377
1822
|
}
|
|
1378
|
-
if (dataDir &&
|
|
1379
|
-
setEditTarget(
|
|
1823
|
+
if (dataDir && curEntry && key.ctrl && input === "e") {
|
|
1824
|
+
setEditTarget(curEntry);
|
|
1380
1825
|
setMode("edit");
|
|
1381
1826
|
return;
|
|
1382
1827
|
}
|
|
1383
|
-
if (dataDir &&
|
|
1384
|
-
setPendingDelete(
|
|
1828
|
+
if (dataDir && curEntry && key.ctrl && input === "x") {
|
|
1829
|
+
setPendingDelete(curEntry);
|
|
1385
1830
|
return;
|
|
1386
1831
|
}
|
|
1387
1832
|
if (key.ctrl && input === "f") {
|
|
1388
1833
|
setMode("filter");
|
|
1389
1834
|
return;
|
|
1390
1835
|
}
|
|
1391
|
-
if (dataDir &&
|
|
1392
|
-
const next = toggleFavorite(dataDir,
|
|
1836
|
+
if (dataDir && curEntry && key.ctrl && input === "s") {
|
|
1837
|
+
const next = toggleFavorite(dataDir, curEntry.app, curEntry.action);
|
|
1393
1838
|
setFavorites(next);
|
|
1394
|
-
setFlash(next.has(favKey(
|
|
1839
|
+
setFlash(next.has(favKey(curEntry.app, curEntry.action)) ? "★ starred" : "☆ unstarred");
|
|
1395
1840
|
return;
|
|
1396
1841
|
}
|
|
1397
1842
|
if (key.downArrow || key.ctrl && input === "n") {
|
|
@@ -1403,28 +1848,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1403
1848
|
return;
|
|
1404
1849
|
}
|
|
1405
1850
|
if (key.return) {
|
|
1406
|
-
if (
|
|
1407
|
-
return;
|
|
1408
|
-
}
|
|
1409
|
-
if (key.meta && (key.backspace || key.delete) || key.ctrl && input === "w") {
|
|
1410
|
-
setQuery(deleteWordBack);
|
|
1411
|
-
setSelected(0);
|
|
1851
|
+
if (curEntry) setFlash(onCopy(curEntry.keys ?? curEntry.command ?? `${curEntry.app}: ${curEntry.action}`) ? "✓ copied!" : "✗ copy failed");
|
|
1412
1852
|
return;
|
|
1413
1853
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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);
|
|
1417
1860
|
return;
|
|
1418
1861
|
}
|
|
1419
|
-
if (key.backspace || key.delete) {
|
|
1420
|
-
setQuery((q) => q.slice(0, -1));
|
|
1421
|
-
setSelected(0);
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
if (input && !key.ctrl && !key.meta) {
|
|
1425
|
-
setQuery((q) => q + input);
|
|
1426
|
-
setSelected(0);
|
|
1427
|
-
}
|
|
1428
1862
|
}, { isActive: mode === "search" });
|
|
1429
1863
|
const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
|
|
1430
1864
|
if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
|
|
@@ -1478,17 +1912,20 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
|
|
|
1478
1912
|
setEditTarget(null);
|
|
1479
1913
|
}
|
|
1480
1914
|
});
|
|
1915
|
+
const filterLabelText = filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0;
|
|
1481
1916
|
return /* @__PURE__ */ jsxs(Box, {
|
|
1482
1917
|
flexDirection: "column",
|
|
1483
1918
|
children: [
|
|
1484
1919
|
/* @__PURE__ */ jsx(SearchInput, {
|
|
1485
|
-
query,
|
|
1486
|
-
|
|
1920
|
+
query: query.text,
|
|
1921
|
+
pos: query.pos,
|
|
1922
|
+
width: searchFieldWidth(stdout?.columns, filterLabelText?.length ?? 0),
|
|
1923
|
+
filterLabel: filterLabelText
|
|
1487
1924
|
}),
|
|
1488
1925
|
/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
|
|
1489
1926
|
results,
|
|
1490
1927
|
selected: sel,
|
|
1491
|
-
query,
|
|
1928
|
+
query: query.text,
|
|
1492
1929
|
height: listHeight,
|
|
1493
1930
|
width: left,
|
|
1494
1931
|
favorites,
|