@arthony/keybook 0.5.0 → 0.7.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.
Files changed (2) hide show
  1. package/dist/cli.js +355 -70
  2. package/package.json +33 -20
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  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, 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";
@@ -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) {
@@ -412,29 +429,40 @@ function runAdd(dir, draft) {
412
429
  }
413
430
  //#endregion
414
431
  //#region src/tui/StepsBuilder.tsx
415
- function StepsBuilder({ steps, line, active }) {
432
+ function StepsBuilder({ steps, line, active, cursor, grabbed = false }) {
433
+ const cur = cursor ?? steps.length;
434
+ const onAppendLine = cur >= steps.length;
435
+ const hint = 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";
416
436
  return /* @__PURE__ */ jsxs(Box, {
417
437
  flexDirection: "column",
418
438
  children: [
419
- steps.map((s, i) => /* @__PURE__ */ jsxs(Text, { children: [
420
- i + 1,
421
- ". ",
422
- s
423
- ] }, i)),
439
+ steps.map((s, i) => {
440
+ return /* @__PURE__ */ jsxs(Text, { children: [
441
+ active && cur === i ? grabbed ? " " : "> " : " ",
442
+ i + 1,
443
+ ". ",
444
+ s
445
+ ] }, i);
446
+ }),
424
447
  /* @__PURE__ */ jsxs(Box, { children: [
425
448
  /* @__PURE__ */ jsxs(Text, {
426
- color: active ? "cyan" : "gray",
427
- children: [steps.length + 1, ". "]
449
+ color: active && onAppendLine ? "cyan" : "gray",
450
+ children: [
451
+ " ",
452
+ steps.length + 1,
453
+ ".",
454
+ " "
455
+ ]
428
456
  }),
429
457
  /* @__PURE__ */ jsx(Text, { children: line }),
430
- active ? /* @__PURE__ */ jsx(Text, {
458
+ active && onAppendLine ? /* @__PURE__ */ jsx(Text, {
431
459
  inverse: true,
432
460
  children: " "
433
461
  }) : null
434
462
  ] }),
435
- steps.length === 0 ? /* @__PURE__ */ jsx(Text, {
463
+ active ? /* @__PURE__ */ jsx(Text, {
436
464
  color: "gray",
437
- children: "⏎ adds a step · ⌫ on an empty line removes the last"
465
+ children: hint
438
466
  }) : null
439
467
  ]
440
468
  });
@@ -459,7 +487,7 @@ function Field({ label, value, focused }) {
459
487
  }) : null
460
488
  ] });
461
489
  }
462
- function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp }) {
490
+ function FormFields({ draft, apps, appIndex, focused, existingTags, stepCursor, grabbed }) {
463
491
  const appChoices = [...apps, "Create new app…"];
464
492
  return /* @__PURE__ */ jsxs(Box, {
465
493
  flexDirection: "column",
@@ -467,10 +495,7 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
467
495
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
468
496
  color: focused === 0 ? "cyan" : "gray",
469
497
  children: "App".padEnd(8)
470
- }), lockedApp ? /* @__PURE__ */ jsx(Text, {
471
- color: "gray",
472
- children: `${lockedApp} (locked)`
473
- }) : draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
498
+ }), draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
474
499
  inverse: true,
475
500
  children: " "
476
501
  }) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
@@ -509,7 +534,9 @@ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp })
509
534
  }), /* @__PURE__ */ jsx(StepsBuilder, {
510
535
  steps: draft.steps,
511
536
  line: draft.stepLine,
512
- active: focused === 3
537
+ active: focused === 3,
538
+ cursor: stepCursor,
539
+ grabbed
513
540
  })]
514
541
  }),
515
542
  /* @__PURE__ */ jsx(Field, {
@@ -656,6 +683,20 @@ const emptyDraft = {
656
683
  function parseTags(raw) {
657
684
  return raw.split(",").map((t) => t.trim()).filter(Boolean);
658
685
  }
686
+ /** Move the step at `from` to position `to`, returning a NEW array. No-op if out of range or from===to. */
687
+ function moveStep(steps, from, to) {
688
+ if (from < 0 || from >= steps.length || to < 0 || to >= steps.length || from === to) return steps;
689
+ const next = [...steps];
690
+ const [moved] = next.splice(from, 1);
691
+ if (moved === void 0) return steps;
692
+ next.splice(to, 0, moved);
693
+ return next;
694
+ }
695
+ /** Remove the step at `i`, returning a NEW array. No-op if out of range. */
696
+ function deleteStep(steps, i) {
697
+ if (i < 0 || i >= steps.length) return steps;
698
+ return steps.filter((_, idx) => idx !== i);
699
+ }
659
700
  function resolvedApp(d) {
660
701
  return (d.creatingApp ? d.newApp : d.app).trim();
661
702
  }
@@ -727,13 +768,28 @@ const TYPES = [
727
768
  "recipe"
728
769
  ];
729
770
  const LAST_FIELD = 5;
730
- function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, lockedApp, title }) {
771
+ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, initialFocus, title }) {
731
772
  const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
732
- const [focused, setFocused] = useState(lockedApp ? 1 : 0);
733
- const [appIndex, setAppIndex] = useState(0);
773
+ const [focused, setFocused] = useState(initialFocus ?? 0);
774
+ const [stepCursor, setStepCursor] = useState(initial?.steps?.length ?? 0);
775
+ const [grabbed, setGrabbed] = useState(false);
776
+ const [appIndex, setAppIndex] = useState(() => {
777
+ const i = apps.indexOf(initial?.app ?? apps[0] ?? "");
778
+ return i >= 0 ? i : 0;
779
+ });
734
780
  const [screen, setScreen] = useState("form");
735
781
  const [hint, setHint] = useState("");
736
782
  const [writeError, setWriteError] = useState("");
783
+ useEffect(() => {
784
+ if (!(focused === 3 && draft.type === "recipe")) {
785
+ setGrabbed(false);
786
+ setStepCursor(draft.steps.length);
787
+ }
788
+ }, [
789
+ focused,
790
+ draft.type,
791
+ draft.steps.length
792
+ ]);
737
793
  const appChoices = [...apps, "Create new app…"];
738
794
  function commitAppSelection(index) {
739
795
  if (index === apps.length) update({ creatingApp: true });
@@ -767,9 +823,10 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
767
823
  }
768
824
  return;
769
825
  }
826
+ if (grabbed && key.escape) return setGrabbed(false);
770
827
  if (key.escape) return onCancel();
771
828
  if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
772
- if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, lockedApp ? 1 : 0));
829
+ if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, 0));
773
830
  if (focused === 0) {
774
831
  if (key.upArrow) {
775
832
  const next = Math.max(appIndex - 1, 0);
@@ -799,18 +856,51 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
799
856
  return;
800
857
  }
801
858
  if (focused === 3 && draft.type === "recipe") {
802
- if (key.return) {
803
- if (draft.stepLine.trim()) update({
804
- steps: [...draft.steps, draft.stepLine.trim()],
805
- stepLine: ""
806
- });
859
+ const steps = draft.steps;
860
+ const onAppendLine = stepCursor >= steps.length;
861
+ if (grabbed) {
862
+ if (key.upArrow && stepCursor > 0) {
863
+ update({ steps: moveStep(steps, stepCursor, stepCursor - 1) });
864
+ return setStepCursor(stepCursor - 1);
865
+ }
866
+ if (key.downArrow && stepCursor < steps.length - 1) {
867
+ update({ steps: moveStep(steps, stepCursor, stepCursor + 1) });
868
+ return setStepCursor(stepCursor + 1);
869
+ }
870
+ if (key.return || input === " " || key.escape) return setGrabbed(false);
807
871
  return;
808
872
  }
873
+ if (onAppendLine) {
874
+ if (key.return) {
875
+ if (draft.stepLine.trim()) {
876
+ update({
877
+ steps: [...steps, draft.stepLine.trim()],
878
+ stepLine: ""
879
+ });
880
+ setStepCursor(steps.length + 1);
881
+ }
882
+ return;
883
+ }
884
+ if (key.backspace || key.delete) {
885
+ if (draft.stepLine) return update({ stepLine: draft.stepLine.slice(0, -1) });
886
+ if (steps.length) return setStepCursor(steps.length - 1);
887
+ return;
888
+ }
889
+ if (key.upArrow) {
890
+ if (steps.length) return setStepCursor(steps.length - 1);
891
+ return;
892
+ }
893
+ if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
894
+ return;
895
+ }
896
+ if (key.upArrow) return setStepCursor(Math.max(0, stepCursor - 1));
897
+ if (key.downArrow) return setStepCursor(stepCursor + 1);
898
+ if (key.return || input === " ") return setGrabbed(true);
809
899
  if (key.backspace || key.delete) {
810
- if (draft.stepLine) return update({ stepLine: draft.stepLine.slice(0, -1) });
811
- return update({ steps: draft.steps.slice(0, -1) });
900
+ const next = deleteStep(steps, stepCursor);
901
+ update({ steps: next });
902
+ return setStepCursor(Math.min(stepCursor, next.length));
812
903
  }
813
- if (input && !key.ctrl && !key.meta) return update({ stepLine: draft.stepLine + input });
814
904
  return;
815
905
  }
816
906
  if (key.return) return goReview();
@@ -840,7 +930,8 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
840
930
  appIndex,
841
931
  focused,
842
932
  existingTags,
843
- lockedApp
933
+ stepCursor,
934
+ grabbed
844
935
  })
845
936
  }),
846
937
  /* @__PURE__ */ jsxs(Box, {
@@ -868,6 +959,52 @@ function copyToClipboard(text) {
868
959
  }
869
960
  }
870
961
  //#endregion
962
+ //#region src/data/favorites.ts
963
+ const SEP = "\0";
964
+ function favKey(app, action) {
965
+ return `${app}${SEP}${action}`;
966
+ }
967
+ function favoritesPath(dir) {
968
+ return join(dir, "favorites.json");
969
+ }
970
+ /** Read favorites.json -> Set of favKeys. Missing/malformed -> empty set (never throws). */
971
+ function loadFavorites(dir) {
972
+ const set = /* @__PURE__ */ new Set();
973
+ try {
974
+ const data = JSON.parse(readFileSync(favoritesPath(dir), "utf8"));
975
+ const list = Array.isArray(data?.favorites) ? data.favorites : [];
976
+ for (const f of list) if (f && typeof f.app === "string" && typeof f.action === "string") set.add(favKey(f.app, f.action));
977
+ } catch {}
978
+ return set;
979
+ }
980
+ /** Atomic write (temp + rename) of the favKey set back to favorites.json. */
981
+ function saveFavorites(dir, keys) {
982
+ const data = {
983
+ version: 1,
984
+ favorites: [...keys].map((k) => {
985
+ const i = k.indexOf(SEP);
986
+ return {
987
+ app: k.slice(0, i),
988
+ action: k.slice(i + 1)
989
+ };
990
+ })
991
+ };
992
+ const path = favoritesPath(dir);
993
+ const tmp = `${path}.tmp`;
994
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}
995
+ `, "utf8");
996
+ renameSync(tmp, path);
997
+ }
998
+ /** Toggle one favorite, persist, and return the new set. */
999
+ function toggleFavorite(dir, app, action) {
1000
+ const keys = loadFavorites(dir);
1001
+ const k = favKey(app, action);
1002
+ if (keys.has(k)) keys.delete(k);
1003
+ else keys.add(k);
1004
+ saveFavorites(dir, keys);
1005
+ return keys;
1006
+ }
1007
+ //#endregion
871
1008
  //#region src/search.ts
872
1009
  function haystack(e) {
873
1010
  return [
@@ -888,8 +1025,109 @@ function search(entries, query) {
888
1025
  }).find(q).map((r) => r.item);
889
1026
  }
890
1027
  //#endregion
1028
+ //#region src/tui/FilterPicker.tsx
1029
+ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
1030
+ const [query, setQuery] = useState("");
1031
+ const [selected, setSelected] = useState(0);
1032
+ const matchedApps = useMemo(() => {
1033
+ const q = query.trim().toLowerCase();
1034
+ return q ? apps.filter((a) => a.toLowerCase().includes(q)) : apps;
1035
+ }, [apps, query]);
1036
+ const total = 2 + matchedApps.length;
1037
+ const sel = Math.min(selected, Math.max(0, total - 1));
1038
+ useInput((input, key) => {
1039
+ if (key.escape || key.ctrl && input === "f") return onCancel();
1040
+ if (key.downArrow || key.ctrl && input === "n") {
1041
+ setSelected((s) => Math.min(s + 1, total - 1));
1042
+ return;
1043
+ }
1044
+ if (key.upArrow || key.ctrl && input === "p") {
1045
+ setSelected((s) => Math.max(s - 1, 0));
1046
+ return;
1047
+ }
1048
+ if (key.return) {
1049
+ if (sel === 0) return onSelect({ type: "favorites" });
1050
+ if (sel === 1) return onSelect({ type: "all" });
1051
+ const app = matchedApps[sel - 2];
1052
+ if (app) return onSelect({
1053
+ type: "app",
1054
+ app
1055
+ });
1056
+ return;
1057
+ }
1058
+ if (key.backspace || key.delete) {
1059
+ setQuery((q) => q.slice(0, -1));
1060
+ setSelected(0);
1061
+ return;
1062
+ }
1063
+ if (input && !key.ctrl && !key.meta) {
1064
+ setQuery((q) => q + input);
1065
+ setSelected(2);
1066
+ }
1067
+ });
1068
+ const bodyHeight = Math.max(1, height - 2);
1069
+ const appSel = Math.max(0, sel - 2);
1070
+ const startApp = matchedApps.length > bodyHeight ? Math.max(0, Math.min(appSel - Math.floor(bodyHeight / 2), matchedApps.length - bodyHeight)) : 0;
1071
+ const visibleApps = matchedApps.slice(startApp, startApp + bodyHeight);
1072
+ const row = (text, idx) => {
1073
+ const isSel = idx === sel;
1074
+ return /* @__PURE__ */ jsxs(Text, {
1075
+ wrap: "truncate-end",
1076
+ color: isSel ? "cyan" : void 0,
1077
+ inverse: isSel,
1078
+ children: [isSel ? "▸ " : " ", text]
1079
+ }, `${idx}:${text}`);
1080
+ };
1081
+ return /* @__PURE__ */ jsxs(Box, {
1082
+ flexDirection: "column",
1083
+ borderStyle: "round",
1084
+ paddingX: 1,
1085
+ width,
1086
+ children: [
1087
+ /* @__PURE__ */ jsx(Text, {
1088
+ wrap: "truncate-end",
1089
+ color: "cyan",
1090
+ children: "Filter by app"
1091
+ }),
1092
+ /* @__PURE__ */ jsxs(Text, {
1093
+ wrap: "truncate-end",
1094
+ children: [query ? query : /* @__PURE__ */ jsx(Text, {
1095
+ color: "gray",
1096
+ children: "type to filter…"
1097
+ }), /* @__PURE__ */ jsx(Text, {
1098
+ inverse: true,
1099
+ children: " "
1100
+ })]
1101
+ }),
1102
+ row("★ Favorites", 0),
1103
+ row("All apps", 1),
1104
+ /* @__PURE__ */ jsx(Text, {
1105
+ wrap: "truncate-end",
1106
+ color: "gray",
1107
+ children: "──────────────"
1108
+ }),
1109
+ startApp > 0 ? /* @__PURE__ */ jsxs(Text, {
1110
+ wrap: "truncate-end",
1111
+ color: "gray",
1112
+ children: [" ", "↑ more"]
1113
+ }) : null,
1114
+ visibleApps.map((app, i) => row(app, startApp + i + 2)),
1115
+ startApp + bodyHeight < matchedApps.length ? /* @__PURE__ */ jsxs(Text, {
1116
+ wrap: "truncate-end",
1117
+ color: "gray",
1118
+ children: [" ", "↓ more"]
1119
+ }) : null,
1120
+ /* @__PURE__ */ jsx(Text, {
1121
+ wrap: "truncate-end",
1122
+ color: "gray",
1123
+ children: "↑↓ move ⏎ select ⎋ cancel"
1124
+ })
1125
+ ]
1126
+ });
1127
+ }
1128
+ //#endregion
891
1129
  //#region src/tui/Footer.tsx
892
- function Footer({ flash, errorCount, resultCount, confirm }) {
1130
+ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
893
1131
  if (confirm) return /* @__PURE__ */ jsxs(Box, {
894
1132
  marginTop: 1,
895
1133
  justifyContent: "space-between",
@@ -906,19 +1144,17 @@ function Footer({ flash, errorCount, resultCount, confirm }) {
906
1144
  return /* @__PURE__ */ jsxs(Box, {
907
1145
  marginTop: 1,
908
1146
  justifyContent: "space-between",
909
- children: [/* @__PURE__ */ jsxs(Text, {
1147
+ children: [/* @__PURE__ */ jsx(Text, {
910
1148
  color: "gray",
911
1149
  wrap: "truncate-end",
912
- children: [
913
- "↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⎋ quit ⌃U clear (",
914
- resultCount,
915
- ")"
916
- ]
1150
+ children: `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} ⌃U clear (${resultCount})`
917
1151
  }), flash ? /* @__PURE__ */ jsx(Text, {
918
1152
  color: "green",
1153
+ wrap: "truncate-end",
919
1154
  children: flash
920
1155
  }) : errorCount > 0 ? /* @__PURE__ */ jsxs(Text, {
921
1156
  color: "yellow",
1157
+ wrap: "truncate-end",
922
1158
  children: [
923
1159
  "⚠ ",
924
1160
  errorCount,
@@ -973,7 +1209,7 @@ function PreviewPane({ entry, width = "50%" }) {
973
1209
  }
974
1210
  //#endregion
975
1211
  //#region src/tui/ResultRow.tsx
976
- function ResultRow({ entry, selected }) {
1212
+ function ResultRow({ entry, selected, favorite = false }) {
977
1213
  const right = entry.keys ?? "recipe";
978
1214
  return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, {
979
1215
  wrap: "truncate-end",
@@ -982,6 +1218,7 @@ function ResultRow({ entry, selected }) {
982
1218
  inverse: selected,
983
1219
  children: [
984
1220
  selected ? "▸ " : " ",
1221
+ favorite ? "★ " : " ",
985
1222
  entry.app,
986
1223
  " · ",
987
1224
  entry.action
@@ -994,17 +1231,13 @@ function ResultRow({ entry, selected }) {
994
1231
  }
995
1232
  //#endregion
996
1233
  //#region src/tui/ResultList.tsx
997
- function ResultList({ results, selected, query, height = 12, width = "50%" }) {
1234
+ function ResultList({ results, selected, query, height = 12, width = "50%", favorites, emptyMessage }) {
998
1235
  if (results.length === 0) return /* @__PURE__ */ jsx(Box, {
999
1236
  width,
1000
1237
  justifyContent: "center",
1001
- children: /* @__PURE__ */ jsxs(Text, {
1238
+ children: /* @__PURE__ */ jsx(Text, {
1002
1239
  color: "gray",
1003
- children: [
1004
- "No matches for \"",
1005
- query,
1006
- "\""
1007
- ]
1240
+ children: emptyMessage ?? `No matches for "${query}"`
1008
1241
  })
1009
1242
  });
1010
1243
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), results.length - height));
@@ -1013,27 +1246,35 @@ function ResultList({ results, selected, query, height = 12, width = "50%" }) {
1013
1246
  width,
1014
1247
  children: results.slice(Math.max(0, start), Math.max(0, start) + height).map((e, i) => /* @__PURE__ */ jsx(ResultRow, {
1015
1248
  entry: e,
1016
- selected: Math.max(0, start) + i === selected
1249
+ selected: Math.max(0, start) + i === selected,
1250
+ favorite: favorites?.has(favKey(e.app, e.action)) ?? false
1017
1251
  }, `${e.app}:${e.action}`))
1018
1252
  });
1019
1253
  }
1020
1254
  //#endregion
1021
1255
  //#region src/tui/SearchInput.tsx
1022
- function SearchInput({ query }) {
1023
- return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, {
1024
- wrap: "truncate-end",
1025
- children: [
1026
- /* @__PURE__ */ jsx(Text, {
1027
- color: "cyan",
1028
- children: "search: "
1029
- }),
1030
- /* @__PURE__ */ jsx(Text, { children: query }),
1031
- /* @__PURE__ */ jsx(Text, {
1032
- inverse: true,
1033
- children: " "
1034
- })
1035
- ]
1036
- }) });
1256
+ function SearchInput({ query, filterLabel }) {
1257
+ return /* @__PURE__ */ jsxs(Box, {
1258
+ justifyContent: "space-between",
1259
+ children: [/* @__PURE__ */ jsxs(Text, {
1260
+ wrap: "truncate-end",
1261
+ children: [
1262
+ /* @__PURE__ */ jsx(Text, {
1263
+ color: "cyan",
1264
+ children: "search: "
1265
+ }),
1266
+ /* @__PURE__ */ jsx(Text, { children: query }),
1267
+ /* @__PURE__ */ jsx(Text, {
1268
+ inverse: true,
1269
+ children: " "
1270
+ })
1271
+ ]
1272
+ }), filterLabel ? /* @__PURE__ */ jsx(Text, {
1273
+ color: "gray",
1274
+ wrap: "truncate-end",
1275
+ children: filterLabel
1276
+ }) : null]
1277
+ });
1037
1278
  }
1038
1279
  //#endregion
1039
1280
  //#region src/tui/input.ts
@@ -1073,11 +1314,14 @@ function columnWidths(terminalCols) {
1073
1314
  }
1074
1315
  //#endregion
1075
1316
  //#region src/tui/App.tsx
1317
+ const sameApp = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1076
1318
  function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboard }) {
1077
1319
  const { exit } = useApp();
1078
1320
  const { stdout } = useStdout();
1079
1321
  const [entries, setEntries] = useState(initial);
1080
1322
  const [mode, setMode] = useState("search");
1323
+ const [filter, setFilter] = useState({ type: "all" });
1324
+ const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
1081
1325
  const [editTarget, setEditTarget] = useState(null);
1082
1326
  const [pendingDelete, setPendingDelete] = useState(null);
1083
1327
  const [query, setQuery] = useState("");
@@ -1086,7 +1330,16 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1086
1330
  const reload = useCallback(() => {
1087
1331
  if (dataDir) setEntries(loadEntries(dataDir).entries);
1088
1332
  }, [dataDir]);
1089
- const results = useMemo(() => search(entries, query), [entries, query]);
1333
+ const scoped = useMemo(() => {
1334
+ if (filter.type === "app") return entries.filter((e) => e.app === filter.app);
1335
+ if (filter.type === "favorites") return entries.filter((e) => favorites.has(favKey(e.app, e.action)));
1336
+ return entries;
1337
+ }, [
1338
+ entries,
1339
+ filter,
1340
+ favorites
1341
+ ]);
1342
+ const results = useMemo(() => search(scoped, query), [scoped, query]);
1090
1343
  const sel = results.length ? Math.min(selected, results.length - 1) : 0;
1091
1344
  const current = results[sel];
1092
1345
  const listHeight = visibleListHeight(stdout?.rows);
@@ -1110,6 +1363,11 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1110
1363
  }
1111
1364
  if (!key.return && flash) setFlash("");
1112
1365
  if (key.escape) {
1366
+ if (filter.type !== "all") {
1367
+ setFilter({ type: "all" });
1368
+ setSelected(0);
1369
+ return;
1370
+ }
1113
1371
  exit();
1114
1372
  return;
1115
1373
  }
@@ -1126,6 +1384,16 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1126
1384
  setPendingDelete(current);
1127
1385
  return;
1128
1386
  }
1387
+ if (key.ctrl && input === "f") {
1388
+ setMode("filter");
1389
+ return;
1390
+ }
1391
+ if (dataDir && current && key.ctrl && input === "s") {
1392
+ const next = toggleFavorite(dataDir, current.app, current.action);
1393
+ setFavorites(next);
1394
+ setFlash(next.has(favKey(current.app, current.action)) ? "★ starred" : "☆ unstarred");
1395
+ return;
1396
+ }
1129
1397
  if (key.downArrow || key.ctrl && input === "n") {
1130
1398
  setSelected((s) => Math.min(s + 1, results.length - 1));
1131
1399
  return;
@@ -1159,6 +1427,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1159
1427
  }
1160
1428
  }, { isActive: mode === "search" });
1161
1429
  const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1430
+ if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
1431
+ apps: [...new Set(entries.map((e) => e.app))].sort(),
1432
+ height: listHeight,
1433
+ width: left,
1434
+ onSelect: (f) => {
1435
+ setFilter(f);
1436
+ setSelected(0);
1437
+ setMode("search");
1438
+ },
1439
+ onCancel: () => setMode("search")
1440
+ });
1162
1441
  if (mode === "add" && dataDir) return /* @__PURE__ */ jsx(AddEntryForm, {
1163
1442
  apps: listApps(dataDir),
1164
1443
  existingTags,
@@ -1177,14 +1456,14 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1177
1456
  if (mode === "edit" && dataDir && editTarget) return /* @__PURE__ */ jsx(AddEntryForm, {
1178
1457
  apps: listApps(dataDir),
1179
1458
  existingTags,
1180
- lockedApp: editTarget.app,
1459
+ initialFocus: 1,
1181
1460
  initial: entryToDraft(editTarget.app, editTarget),
1182
1461
  title: `Edit entry — ${editTarget.app}`,
1183
- resolveTarget: () => ({
1462
+ resolveTarget: (app) => sameApp(app, editTarget.app) ? {
1184
1463
  file: editTarget.file,
1185
1464
  created: false
1186
- }),
1187
- onSubmit: (_app, entry) => editEntry(dataDir, editTarget.file, editTarget.index, entry, editTarget.action),
1465
+ } : resolveTargetFile(dataDir, app),
1466
+ 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),
1188
1467
  onComplete: (result) => {
1189
1468
  if (result.ok) {
1190
1469
  reload();
@@ -1202,13 +1481,18 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1202
1481
  return /* @__PURE__ */ jsxs(Box, {
1203
1482
  flexDirection: "column",
1204
1483
  children: [
1205
- /* @__PURE__ */ jsx(SearchInput, { query }),
1484
+ /* @__PURE__ */ jsx(SearchInput, {
1485
+ query,
1486
+ filterLabel: filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0
1487
+ }),
1206
1488
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
1207
1489
  results,
1208
1490
  selected: sel,
1209
1491
  query,
1210
1492
  height: listHeight,
1211
- width: left
1493
+ width: left,
1494
+ favorites,
1495
+ emptyMessage: filter.type === "favorites" ? "No favorites yet — ⌃S stars an entry." : void 0
1212
1496
  }), /* @__PURE__ */ jsx(PreviewPane, {
1213
1497
  entry: current,
1214
1498
  width: right
@@ -1217,6 +1501,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1217
1501
  flash,
1218
1502
  errorCount,
1219
1503
  resultCount: results.length,
1504
+ filterActive: filter.type !== "all",
1220
1505
  confirm: pendingDelete ? `Delete '${pendingDelete.app}: ${pendingDelete.action}'?` : void 0
1221
1506
  })
1222
1507
  ]
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.5.0",
4
- "publishConfig": { "access": "public" },
3
+ "version": "0.7.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
5
7
  "description": "A macOS TUI for searching keyboard shortcuts and recipes of your favorite apps",
6
8
  "keywords": [
7
9
  "cli",
@@ -15,22 +17,27 @@
15
17
  "productivity"
16
18
  ],
17
19
  "homepage": "https://github.com/Ariyapong/keybook#readme",
18
- "repository": { "type": "git", "url": "git+https://github.com/Ariyapong/keybook.git" },
19
- "bugs": { "url": "https://github.com/Ariyapong/keybook/issues" },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Ariyapong/keybook.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Ariyapong/keybook/issues"
26
+ },
20
27
  "author": "Ariyapong Wimolnoch",
21
28
  "type": "module",
22
- "bin": { "keybook": "dist/cli.js", "kb": "dist/cli.js" },
23
- "files": ["dist", "seed", "README.md", "LICENSE"],
24
- "engines": { "node": ">=22" },
25
- "scripts": {
26
- "build": "tsdown",
27
- "dev": "tsx src/cli.ts",
28
- "test": "vitest run",
29
- "test:watch": "vitest",
30
- "typecheck": "tsc --noEmit",
31
- "lint": "biome check .",
32
- "format": "biome format --write .",
33
- "prepublishOnly": "pnpm build"
29
+ "bin": {
30
+ "keybook": "dist/cli.js",
31
+ "kb": "dist/cli.js"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "seed",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22"
34
41
  },
35
42
  "dependencies": {
36
43
  "commander": "^12.1.0",
@@ -51,8 +58,14 @@
51
58
  "vitest": "^2.1.0"
52
59
  },
53
60
  "license": "MIT",
54
- "packageManager": "pnpm@10.25.0",
55
- "pnpm": {
56
- "onlyBuiltDependencies": ["@biomejs/biome", "esbuild"]
61
+ "scripts": {
62
+ "build": "tsdown",
63
+ "dev": "tsx src/cli.ts",
64
+ "test": "vitest run",
65
+ "test:watch": "vitest",
66
+ "typecheck": "tsc --noEmit",
67
+ "lint": "biome check .",
68
+ "format": "biome format --write .",
69
+ "bump-tap": "./scripts/bump-tap.sh"
57
70
  }
58
- }
71
+ }