@arthony/keybook 0.5.0 → 0.6.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 +228 -35
  2. package/package.json +32 -20
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
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";
@@ -868,6 +868,52 @@ function copyToClipboard(text) {
868
868
  }
869
869
  }
870
870
  //#endregion
871
+ //#region src/data/favorites.ts
872
+ const SEP = "\0";
873
+ function favKey(app, action) {
874
+ return `${app}${SEP}${action}`;
875
+ }
876
+ function favoritesPath(dir) {
877
+ return join(dir, "favorites.json");
878
+ }
879
+ /** Read favorites.json -> Set of favKeys. Missing/malformed -> empty set (never throws). */
880
+ function loadFavorites(dir) {
881
+ const set = /* @__PURE__ */ new Set();
882
+ try {
883
+ const data = JSON.parse(readFileSync(favoritesPath(dir), "utf8"));
884
+ const list = Array.isArray(data?.favorites) ? data.favorites : [];
885
+ for (const f of list) if (f && typeof f.app === "string" && typeof f.action === "string") set.add(favKey(f.app, f.action));
886
+ } catch {}
887
+ return set;
888
+ }
889
+ /** Atomic write (temp + rename) of the favKey set back to favorites.json. */
890
+ function saveFavorites(dir, keys) {
891
+ const data = {
892
+ version: 1,
893
+ favorites: [...keys].map((k) => {
894
+ const i = k.indexOf(SEP);
895
+ return {
896
+ app: k.slice(0, i),
897
+ action: k.slice(i + 1)
898
+ };
899
+ })
900
+ };
901
+ const path = favoritesPath(dir);
902
+ const tmp = `${path}.tmp`;
903
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}
904
+ `, "utf8");
905
+ renameSync(tmp, path);
906
+ }
907
+ /** Toggle one favorite, persist, and return the new set. */
908
+ function toggleFavorite(dir, app, action) {
909
+ const keys = loadFavorites(dir);
910
+ const k = favKey(app, action);
911
+ if (keys.has(k)) keys.delete(k);
912
+ else keys.add(k);
913
+ saveFavorites(dir, keys);
914
+ return keys;
915
+ }
916
+ //#endregion
871
917
  //#region src/search.ts
872
918
  function haystack(e) {
873
919
  return [
@@ -888,8 +934,109 @@ function search(entries, query) {
888
934
  }).find(q).map((r) => r.item);
889
935
  }
890
936
  //#endregion
937
+ //#region src/tui/FilterPicker.tsx
938
+ function FilterPicker({ apps, onSelect, onCancel, height = 16, width = 40 }) {
939
+ const [query, setQuery] = useState("");
940
+ const [selected, setSelected] = useState(0);
941
+ const matchedApps = useMemo(() => {
942
+ const q = query.trim().toLowerCase();
943
+ return q ? apps.filter((a) => a.toLowerCase().includes(q)) : apps;
944
+ }, [apps, query]);
945
+ const total = 2 + matchedApps.length;
946
+ const sel = Math.min(selected, Math.max(0, total - 1));
947
+ useInput((input, key) => {
948
+ if (key.escape || key.ctrl && input === "f") return onCancel();
949
+ if (key.downArrow || key.ctrl && input === "n") {
950
+ setSelected((s) => Math.min(s + 1, total - 1));
951
+ return;
952
+ }
953
+ if (key.upArrow || key.ctrl && input === "p") {
954
+ setSelected((s) => Math.max(s - 1, 0));
955
+ return;
956
+ }
957
+ if (key.return) {
958
+ if (sel === 0) return onSelect({ type: "favorites" });
959
+ if (sel === 1) return onSelect({ type: "all" });
960
+ const app = matchedApps[sel - 2];
961
+ if (app) return onSelect({
962
+ type: "app",
963
+ app
964
+ });
965
+ return;
966
+ }
967
+ if (key.backspace || key.delete) {
968
+ setQuery((q) => q.slice(0, -1));
969
+ setSelected(0);
970
+ return;
971
+ }
972
+ if (input && !key.ctrl && !key.meta) {
973
+ setQuery((q) => q + input);
974
+ setSelected(2);
975
+ }
976
+ });
977
+ const bodyHeight = Math.max(1, height - 2);
978
+ const appSel = Math.max(0, sel - 2);
979
+ const startApp = matchedApps.length > bodyHeight ? Math.max(0, Math.min(appSel - Math.floor(bodyHeight / 2), matchedApps.length - bodyHeight)) : 0;
980
+ const visibleApps = matchedApps.slice(startApp, startApp + bodyHeight);
981
+ const row = (text, idx) => {
982
+ const isSel = idx === sel;
983
+ return /* @__PURE__ */ jsxs(Text, {
984
+ wrap: "truncate-end",
985
+ color: isSel ? "cyan" : void 0,
986
+ inverse: isSel,
987
+ children: [isSel ? "▸ " : " ", text]
988
+ }, `${idx}:${text}`);
989
+ };
990
+ return /* @__PURE__ */ jsxs(Box, {
991
+ flexDirection: "column",
992
+ borderStyle: "round",
993
+ paddingX: 1,
994
+ width,
995
+ children: [
996
+ /* @__PURE__ */ jsx(Text, {
997
+ wrap: "truncate-end",
998
+ color: "cyan",
999
+ children: "Filter by app"
1000
+ }),
1001
+ /* @__PURE__ */ jsxs(Text, {
1002
+ wrap: "truncate-end",
1003
+ children: [query ? query : /* @__PURE__ */ jsx(Text, {
1004
+ color: "gray",
1005
+ children: "type to filter…"
1006
+ }), /* @__PURE__ */ jsx(Text, {
1007
+ inverse: true,
1008
+ children: " "
1009
+ })]
1010
+ }),
1011
+ row("★ Favorites", 0),
1012
+ row("All apps", 1),
1013
+ /* @__PURE__ */ jsx(Text, {
1014
+ wrap: "truncate-end",
1015
+ color: "gray",
1016
+ children: "──────────────"
1017
+ }),
1018
+ startApp > 0 ? /* @__PURE__ */ jsxs(Text, {
1019
+ wrap: "truncate-end",
1020
+ color: "gray",
1021
+ children: [" ", "↑ more"]
1022
+ }) : null,
1023
+ visibleApps.map((app, i) => row(app, startApp + i + 2)),
1024
+ startApp + bodyHeight < matchedApps.length ? /* @__PURE__ */ jsxs(Text, {
1025
+ wrap: "truncate-end",
1026
+ color: "gray",
1027
+ children: [" ", "↓ more"]
1028
+ }) : null,
1029
+ /* @__PURE__ */ jsx(Text, {
1030
+ wrap: "truncate-end",
1031
+ color: "gray",
1032
+ children: "↑↓ move ⏎ select ⎋ cancel"
1033
+ })
1034
+ ]
1035
+ });
1036
+ }
1037
+ //#endregion
891
1038
  //#region src/tui/Footer.tsx
892
- function Footer({ flash, errorCount, resultCount, confirm }) {
1039
+ function Footer({ flash, errorCount, resultCount, confirm, filterActive = false }) {
893
1040
  if (confirm) return /* @__PURE__ */ jsxs(Box, {
894
1041
  marginTop: 1,
895
1042
  justifyContent: "space-between",
@@ -906,19 +1053,17 @@ function Footer({ flash, errorCount, resultCount, confirm }) {
906
1053
  return /* @__PURE__ */ jsxs(Box, {
907
1054
  marginTop: 1,
908
1055
  justifyContent: "space-between",
909
- children: [/* @__PURE__ */ jsxs(Text, {
1056
+ children: [/* @__PURE__ */ jsx(Text, {
910
1057
  color: "gray",
911
1058
  wrap: "truncate-end",
912
- children: [
913
- "↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⎋ quit ⌃U clear (",
914
- resultCount,
915
- ")"
916
- ]
1059
+ children: `↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⌃F filter ⌃S star ${filterActive ? "⎋ clear filter" : "⎋ quit"} ⌃U clear (${resultCount})`
917
1060
  }), flash ? /* @__PURE__ */ jsx(Text, {
918
1061
  color: "green",
1062
+ wrap: "truncate-end",
919
1063
  children: flash
920
1064
  }) : errorCount > 0 ? /* @__PURE__ */ jsxs(Text, {
921
1065
  color: "yellow",
1066
+ wrap: "truncate-end",
922
1067
  children: [
923
1068
  "⚠ ",
924
1069
  errorCount,
@@ -973,7 +1118,7 @@ function PreviewPane({ entry, width = "50%" }) {
973
1118
  }
974
1119
  //#endregion
975
1120
  //#region src/tui/ResultRow.tsx
976
- function ResultRow({ entry, selected }) {
1121
+ function ResultRow({ entry, selected, favorite = false }) {
977
1122
  const right = entry.keys ?? "recipe";
978
1123
  return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, {
979
1124
  wrap: "truncate-end",
@@ -982,6 +1127,7 @@ function ResultRow({ entry, selected }) {
982
1127
  inverse: selected,
983
1128
  children: [
984
1129
  selected ? "▸ " : " ",
1130
+ favorite ? "★ " : " ",
985
1131
  entry.app,
986
1132
  " · ",
987
1133
  entry.action
@@ -994,17 +1140,13 @@ function ResultRow({ entry, selected }) {
994
1140
  }
995
1141
  //#endregion
996
1142
  //#region src/tui/ResultList.tsx
997
- function ResultList({ results, selected, query, height = 12, width = "50%" }) {
1143
+ function ResultList({ results, selected, query, height = 12, width = "50%", favorites, emptyMessage }) {
998
1144
  if (results.length === 0) return /* @__PURE__ */ jsx(Box, {
999
1145
  width,
1000
1146
  justifyContent: "center",
1001
- children: /* @__PURE__ */ jsxs(Text, {
1147
+ children: /* @__PURE__ */ jsx(Text, {
1002
1148
  color: "gray",
1003
- children: [
1004
- "No matches for \"",
1005
- query,
1006
- "\""
1007
- ]
1149
+ children: emptyMessage ?? `No matches for "${query}"`
1008
1150
  })
1009
1151
  });
1010
1152
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), results.length - height));
@@ -1013,27 +1155,35 @@ function ResultList({ results, selected, query, height = 12, width = "50%" }) {
1013
1155
  width,
1014
1156
  children: results.slice(Math.max(0, start), Math.max(0, start) + height).map((e, i) => /* @__PURE__ */ jsx(ResultRow, {
1015
1157
  entry: e,
1016
- selected: Math.max(0, start) + i === selected
1158
+ selected: Math.max(0, start) + i === selected,
1159
+ favorite: favorites?.has(favKey(e.app, e.action)) ?? false
1017
1160
  }, `${e.app}:${e.action}`))
1018
1161
  });
1019
1162
  }
1020
1163
  //#endregion
1021
1164
  //#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
- }) });
1165
+ function SearchInput({ query, filterLabel }) {
1166
+ return /* @__PURE__ */ jsxs(Box, {
1167
+ justifyContent: "space-between",
1168
+ children: [/* @__PURE__ */ jsxs(Text, {
1169
+ wrap: "truncate-end",
1170
+ children: [
1171
+ /* @__PURE__ */ jsx(Text, {
1172
+ color: "cyan",
1173
+ children: "search: "
1174
+ }),
1175
+ /* @__PURE__ */ jsx(Text, { children: query }),
1176
+ /* @__PURE__ */ jsx(Text, {
1177
+ inverse: true,
1178
+ children: " "
1179
+ })
1180
+ ]
1181
+ }), filterLabel ? /* @__PURE__ */ jsx(Text, {
1182
+ color: "gray",
1183
+ wrap: "truncate-end",
1184
+ children: filterLabel
1185
+ }) : null]
1186
+ });
1037
1187
  }
1038
1188
  //#endregion
1039
1189
  //#region src/tui/input.ts
@@ -1078,6 +1228,8 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1078
1228
  const { stdout } = useStdout();
1079
1229
  const [entries, setEntries] = useState(initial);
1080
1230
  const [mode, setMode] = useState("search");
1231
+ const [filter, setFilter] = useState({ type: "all" });
1232
+ const [favorites, setFavorites] = useState(() => dataDir ? loadFavorites(dataDir) : /* @__PURE__ */ new Set());
1081
1233
  const [editTarget, setEditTarget] = useState(null);
1082
1234
  const [pendingDelete, setPendingDelete] = useState(null);
1083
1235
  const [query, setQuery] = useState("");
@@ -1086,7 +1238,16 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1086
1238
  const reload = useCallback(() => {
1087
1239
  if (dataDir) setEntries(loadEntries(dataDir).entries);
1088
1240
  }, [dataDir]);
1089
- const results = useMemo(() => search(entries, query), [entries, query]);
1241
+ const scoped = useMemo(() => {
1242
+ if (filter.type === "app") return entries.filter((e) => e.app === filter.app);
1243
+ if (filter.type === "favorites") return entries.filter((e) => favorites.has(favKey(e.app, e.action)));
1244
+ return entries;
1245
+ }, [
1246
+ entries,
1247
+ filter,
1248
+ favorites
1249
+ ]);
1250
+ const results = useMemo(() => search(scoped, query), [scoped, query]);
1090
1251
  const sel = results.length ? Math.min(selected, results.length - 1) : 0;
1091
1252
  const current = results[sel];
1092
1253
  const listHeight = visibleListHeight(stdout?.rows);
@@ -1110,6 +1271,11 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1110
1271
  }
1111
1272
  if (!key.return && flash) setFlash("");
1112
1273
  if (key.escape) {
1274
+ if (filter.type !== "all") {
1275
+ setFilter({ type: "all" });
1276
+ setSelected(0);
1277
+ return;
1278
+ }
1113
1279
  exit();
1114
1280
  return;
1115
1281
  }
@@ -1126,6 +1292,16 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1126
1292
  setPendingDelete(current);
1127
1293
  return;
1128
1294
  }
1295
+ if (key.ctrl && input === "f") {
1296
+ setMode("filter");
1297
+ return;
1298
+ }
1299
+ if (dataDir && current && key.ctrl && input === "s") {
1300
+ const next = toggleFavorite(dataDir, current.app, current.action);
1301
+ setFavorites(next);
1302
+ setFlash(next.has(favKey(current.app, current.action)) ? "★ starred" : "☆ unstarred");
1303
+ return;
1304
+ }
1129
1305
  if (key.downArrow || key.ctrl && input === "n") {
1130
1306
  setSelected((s) => Math.min(s + 1, results.length - 1));
1131
1307
  return;
@@ -1159,6 +1335,17 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1159
1335
  }
1160
1336
  }, { isActive: mode === "search" });
1161
1337
  const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1338
+ if (mode === "filter") return /* @__PURE__ */ jsx(FilterPicker, {
1339
+ apps: [...new Set(entries.map((e) => e.app))].sort(),
1340
+ height: listHeight,
1341
+ width: left,
1342
+ onSelect: (f) => {
1343
+ setFilter(f);
1344
+ setSelected(0);
1345
+ setMode("search");
1346
+ },
1347
+ onCancel: () => setMode("search")
1348
+ });
1162
1349
  if (mode === "add" && dataDir) return /* @__PURE__ */ jsx(AddEntryForm, {
1163
1350
  apps: listApps(dataDir),
1164
1351
  existingTags,
@@ -1202,13 +1389,18 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1202
1389
  return /* @__PURE__ */ jsxs(Box, {
1203
1390
  flexDirection: "column",
1204
1391
  children: [
1205
- /* @__PURE__ */ jsx(SearchInput, { query }),
1392
+ /* @__PURE__ */ jsx(SearchInput, {
1393
+ query,
1394
+ filterLabel: filter.type === "app" ? `(filter: ${filter.app})` : filter.type === "favorites" ? "(★ Favorites)" : void 0
1395
+ }),
1206
1396
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(ResultList, {
1207
1397
  results,
1208
1398
  selected: sel,
1209
1399
  query,
1210
1400
  height: listHeight,
1211
- width: left
1401
+ width: left,
1402
+ favorites,
1403
+ emptyMessage: filter.type === "favorites" ? "No favorites yet — ⌃S stars an entry." : void 0
1212
1404
  }), /* @__PURE__ */ jsx(PreviewPane, {
1213
1405
  entry: current,
1214
1406
  width: right
@@ -1217,6 +1409,7 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1217
1409
  flash,
1218
1410
  errorCount,
1219
1411
  resultCount: results.length,
1412
+ filterActive: filter.type !== "all",
1220
1413
  confirm: pendingDelete ? `Delete '${pendingDelete.app}: ${pendingDelete.action}'?` : void 0
1221
1414
  })
1222
1415
  ]
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.6.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,13 @@
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 ."
57
69
  }
58
- }
70
+ }