@arthony/keybook 0.3.2 → 0.4.1

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 CHANGED
@@ -42,8 +42,9 @@ keybook check # validate your data files
42
42
  In the TUI: type to fuzzy-search, ↑/↓ to move, ⏎ to copy the shortcut (or a
43
43
  recipe's command) to the clipboard, ⎋ to quit.
44
44
 
45
- Press `⌃O` in the TUI to add an entry without leaving the search screen. Or
46
- script it: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
45
+ Manage entries without leaving the search screen: `⌃O` to add, `⌃E` to edit the
46
+ selected entry (pre-filled form), `⌃X` to delete it (with a `y/n` confirm). Or
47
+ script an add: `keybook add --app Fork --action 'Push' --keys 'shift cmd p' --tags push`
47
48
  (`--keys` accepts glyphs or words; recipes use repeatable `--step`).
48
49
 
49
50
  ## Your data
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  import { Box, Text, render, useApp, useInput, useStdout } from "ink";
8
8
  import { createElement, useCallback, useMemo, useState } from "react";
9
9
  import { homedir } from "node:os";
10
- import { Document, parse, parseDocument } from "yaml";
10
+ import { Document, isMap, isSeq, parse, parseDocument } from "yaml";
11
11
  import { z } from "zod";
12
12
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
13
  import { Fzf, extendedMatch } from "fzf";
@@ -177,6 +177,8 @@ function loadEntries(dataDir) {
177
177
  }
178
178
  entries.push({
179
179
  app,
180
+ file,
181
+ index: i,
180
182
  ...parsed.data
181
183
  });
182
184
  });
@@ -310,6 +312,73 @@ function addEntry(dir, app, entry) {
310
312
  lines: [`✓ ${created ? "created" : "added to"} ${basename(file)}`]
311
313
  };
312
314
  }
315
+ function deleteEntry(dir, file, index, expectedAction) {
316
+ const path = join(dir, file);
317
+ let original;
318
+ try {
319
+ original = readFileSync(path, "utf8");
320
+ } catch (e) {
321
+ return err(path, [e.message]);
322
+ }
323
+ const doc = parseDocument(original);
324
+ const node = doc.getIn(["entries", index]);
325
+ if ((isMap(node) ? String(node.get("action") ?? "").trim() : void 0) !== expectedAction.trim()) return err(path, ["✗ entry changed on disk — reload and retry"]);
326
+ doc.deleteIn(["entries", index]);
327
+ const seq = doc.getIn(["entries"]);
328
+ const emptied = isSeq(seq) ? seq.items.length === 0 : true;
329
+ try {
330
+ if (emptied) unlinkSync(path);
331
+ else writeFileSync(path, doc.toString());
332
+ } catch (e) {
333
+ return err(path, [e.message]);
334
+ }
335
+ if (!emptied) {
336
+ const fileErr = loadEntries(dir).errors.find((e) => e.file === basename(file));
337
+ if (fileErr) {
338
+ writeFileSync(path, original);
339
+ return err(path, [`✗ ${fileErr.message}`]);
340
+ }
341
+ }
342
+ return {
343
+ ok: true,
344
+ file: path,
345
+ created: false,
346
+ lines: [emptied ? `✗ deleted '${expectedAction}' (removed empty ${basename(file)})` : `✗ deleted '${expectedAction}'`]
347
+ };
348
+ }
349
+ function editEntry(dir, file, index, entry, expectedAction) {
350
+ if ("app" in entry) return err("", ["Error: entry must not have an app field; use --app instead"]);
351
+ const parsed = entrySchema.safeParse(entry);
352
+ if (!parsed.success) return err("", parsed.error.issues.map((i) => i.message));
353
+ const clean = buildClean(parsed.data);
354
+ const path = join(dir, file);
355
+ let original;
356
+ try {
357
+ original = readFileSync(path, "utf8");
358
+ } catch (e) {
359
+ return err(path, [e.message]);
360
+ }
361
+ const doc = parseDocument(original);
362
+ const node = doc.getIn(["entries", index]);
363
+ if ((isMap(node) ? String(node.get("action") ?? "").trim() : void 0) !== expectedAction.trim()) return err(path, ["✗ entry changed on disk — reload and retry"]);
364
+ doc.setIn(["entries", index], doc.createNode(clean));
365
+ try {
366
+ writeFileSync(path, doc.toString());
367
+ } catch (e) {
368
+ return err(path, [e.message]);
369
+ }
370
+ const fileErr = loadEntries(dir).errors.find((e) => e.file === basename(file));
371
+ if (fileErr) {
372
+ writeFileSync(path, original);
373
+ return err(path, [`✗ ${fileErr.message}`]);
374
+ }
375
+ return {
376
+ ok: true,
377
+ file: path,
378
+ created: false,
379
+ lines: [`✓ updated '${clean.action}'`]
380
+ };
381
+ }
313
382
  //#endregion
314
383
  //#region src/commands.ts
315
384
  function runPath(env = process.env) {
@@ -390,7 +459,7 @@ function Field({ label, value, focused }) {
390
459
  }) : null
391
460
  ] });
392
461
  }
393
- function FormFields({ draft, apps, appIndex, focused, existingTags }) {
462
+ function FormFields({ draft, apps, appIndex, focused, existingTags, lockedApp }) {
394
463
  const appChoices = [...apps, "Create new app…"];
395
464
  return /* @__PURE__ */ jsxs(Box, {
396
465
  flexDirection: "column",
@@ -398,7 +467,10 @@ function FormFields({ draft, apps, appIndex, focused, existingTags }) {
398
467
  /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
399
468
  color: focused === 0 ? "cyan" : "gray",
400
469
  children: "App".padEnd(8)
401
- }), draft.creatingApp ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: draft.newApp }), focused === 0 ? /* @__PURE__ */ jsx(Text, {
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, {
402
474
  inverse: true,
403
475
  children: " "
404
476
  }) : null] }) : /* @__PURE__ */ jsxs(Text, { children: [appChoices[appIndex] ?? "—", focused === 0 ? " (↑/↓)" : ""] })] }),
@@ -617,10 +689,25 @@ function draftToEntryInput(d) {
617
689
  if (d.notes.trim()) e.notes = d.notes.trim();
618
690
  return e;
619
691
  }
620
- function useAddForm(initialApp = "") {
692
+ /** Inverse of draftToEntryInput: seed a Draft from an existing entry for editing. */
693
+ function entryToDraft(app, e) {
694
+ const type = e.keys ? "shortcut" : e.command ? "command" : "recipe";
695
+ return {
696
+ ...emptyDraft,
697
+ app,
698
+ type,
699
+ action: e.action,
700
+ keys: e.keys ?? "",
701
+ command: e.command ?? "",
702
+ steps: e.steps ? [...e.steps] : [],
703
+ tags: (e.tags ?? []).join(", "),
704
+ notes: e.notes ?? ""
705
+ };
706
+ }
707
+ function useAddForm(initial = {}) {
621
708
  const [draft, setDraft] = useState(() => ({
622
709
  ...emptyDraft,
623
- app: initialApp
710
+ ...initial
624
711
  }));
625
712
  const update = (patch) => setDraft((d) => ({
626
713
  ...d,
@@ -640,9 +727,9 @@ const TYPES = [
640
727
  "recipe"
641
728
  ];
642
729
  const LAST_FIELD = 5;
643
- function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget }) {
644
- const { draft, update, setDraft } = useAddForm(apps[0] ?? "");
645
- const [focused, setFocused] = useState(0);
730
+ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, resolveTarget, initial, lockedApp, title }) {
731
+ const { draft, update, setDraft } = useAddForm(initial ?? { app: apps[0] ?? "" });
732
+ const [focused, setFocused] = useState(lockedApp ? 1 : 0);
646
733
  const [appIndex, setAppIndex] = useState(0);
647
734
  const [screen, setScreen] = useState("form");
648
735
  const [hint, setHint] = useState("");
@@ -682,7 +769,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
682
769
  }
683
770
  if (key.escape) return onCancel();
684
771
  if (key.ctrl && input === "n") return setFocused((f) => Math.min(f + 1, LAST_FIELD));
685
- if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, 0));
772
+ if (key.ctrl && input === "p") return setFocused((f) => Math.max(f - 1, lockedApp ? 1 : 0));
686
773
  if (focused === 0) {
687
774
  if (key.upArrow) {
688
775
  const next = Math.max(appIndex - 1, 0);
@@ -708,7 +795,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
708
795
  const idx = TYPES.indexOf(draft.type);
709
796
  return update({ type: TYPES[key.leftArrow ? (idx + TYPES.length - 1) % TYPES.length : (idx + 1) % TYPES.length] });
710
797
  }
711
- if (key.return) return goReview();
798
+ if (key.return) return setFocused(2);
712
799
  return;
713
800
  }
714
801
  if (focused === 3 && draft.type === "recipe") {
@@ -731,6 +818,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
731
818
  if (key.backspace || key.delete) return update({ [fieldKey]: draft[fieldKey].slice(0, -1) });
732
819
  if (input && !key.ctrl && !key.meta) return update({ [fieldKey]: draft[fieldKey] + input });
733
820
  });
821
+ const enterHint = focused <= 1 ? "next" : focused === 3 && draft.type === "recipe" ? "add step" : "review";
734
822
  if (screen === "review") return /* @__PURE__ */ jsx(ReviewScreen, {
735
823
  app: resolvedApp(review),
736
824
  entry: draftToEntryInput(review),
@@ -742,7 +830,7 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
742
830
  children: [
743
831
  /* @__PURE__ */ jsx(Text, {
744
832
  color: "cyan",
745
- children: "keybook add"
833
+ children: title ?? "keybook add"
746
834
  }),
747
835
  /* @__PURE__ */ jsx(Box, {
748
836
  marginTop: 1,
@@ -751,15 +839,20 @@ function AddEntryForm({ apps, existingTags, onSubmit, onComplete, onCancel, reso
751
839
  apps,
752
840
  appIndex,
753
841
  focused,
754
- existingTags
842
+ existingTags,
843
+ lockedApp
755
844
  })
756
845
  }),
757
- /* @__PURE__ */ jsx(Box, {
846
+ /* @__PURE__ */ jsxs(Box, {
758
847
  marginTop: 1,
759
- children: /* @__PURE__ */ jsx(Text, {
760
- color: hint ? "red" : "gray",
761
- children: hint || "⌃N next · ⌃P prev · ⏎ review · esc cancel"
762
- })
848
+ flexDirection: "column",
849
+ children: [hint ? /* @__PURE__ */ jsx(Text, {
850
+ color: "red",
851
+ children: hint
852
+ }) : null, /* @__PURE__ */ jsx(Text, {
853
+ color: "gray",
854
+ children: `⌃N next · ⌃P prev · ⏎ ${enterHint} · esc cancel`
855
+ })]
763
856
  })
764
857
  ]
765
858
  });
@@ -796,7 +889,20 @@ function search(entries, query) {
796
889
  }
797
890
  //#endregion
798
891
  //#region src/tui/Footer.tsx
799
- function Footer({ flash, errorCount, resultCount }) {
892
+ function Footer({ flash, errorCount, resultCount, confirm }) {
893
+ if (confirm) return /* @__PURE__ */ jsxs(Box, {
894
+ marginTop: 1,
895
+ justifyContent: "space-between",
896
+ children: [/* @__PURE__ */ jsx(Text, {
897
+ color: "yellow",
898
+ wrap: "truncate-end",
899
+ children: confirm
900
+ }), /* @__PURE__ */ jsx(Text, {
901
+ color: "yellow",
902
+ bold: true,
903
+ children: " y / n"
904
+ })]
905
+ });
800
906
  return /* @__PURE__ */ jsxs(Box, {
801
907
  marginTop: 1,
802
908
  justifyContent: "space-between",
@@ -804,7 +910,7 @@ function Footer({ flash, errorCount, resultCount }) {
804
910
  color: "gray",
805
911
  wrap: "truncate-end",
806
912
  children: [
807
- "↑↓ move ⏎ copy ⌃O add quit ⌥⌫/⌃W del ⌃U clear (",
913
+ "↑↓ move ⏎ copy ⌃O add ⌃E edit ⌃X del ⎋ quit ⌃U clear (",
808
914
  resultCount,
809
915
  ")"
810
916
  ]
@@ -972,6 +1078,8 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
972
1078
  const { stdout } = useStdout();
973
1079
  const [entries, setEntries] = useState(initial);
974
1080
  const [mode, setMode] = useState("search");
1081
+ const [editTarget, setEditTarget] = useState(null);
1082
+ const [pendingDelete, setPendingDelete] = useState(null);
975
1083
  const [query, setQuery] = useState("");
976
1084
  const [selected, setSelected] = useState(0);
977
1085
  const [flash, setFlash] = useState("");
@@ -984,8 +1092,24 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
984
1092
  const listHeight = visibleListHeight(stdout?.rows);
985
1093
  const { left, right } = columnWidths(stdout?.columns);
986
1094
  useInput((input, key) => {
1095
+ if (key.ctrl && input === "c") {
1096
+ exit();
1097
+ return;
1098
+ }
1099
+ if (pendingDelete) {
1100
+ if ((key.return || input === "y" || input === "Y") && dataDir) {
1101
+ const r = deleteEntry(dataDir, pendingDelete.file, pendingDelete.index, pendingDelete.action.trim());
1102
+ if (r.ok) {
1103
+ reload();
1104
+ setSelected(0);
1105
+ }
1106
+ setFlash(r.lines[0] ?? (r.ok ? "✗ deleted" : "✗ delete failed"));
1107
+ } else setFlash("");
1108
+ setPendingDelete(null);
1109
+ return;
1110
+ }
987
1111
  if (!key.return && flash) setFlash("");
988
- if (key.escape || key.ctrl && input === "c") {
1112
+ if (key.escape) {
989
1113
  exit();
990
1114
  return;
991
1115
  }
@@ -993,6 +1117,15 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
993
1117
  setMode("add");
994
1118
  return;
995
1119
  }
1120
+ if (dataDir && current && key.ctrl && input === "e") {
1121
+ setEditTarget(current);
1122
+ setMode("edit");
1123
+ return;
1124
+ }
1125
+ if (dataDir && current && key.ctrl && input === "x") {
1126
+ setPendingDelete(current);
1127
+ return;
1128
+ }
996
1129
  if (key.downArrow || key.ctrl && input === "n") {
997
1130
  setSelected((s) => Math.min(s + 1, results.length - 1));
998
1131
  return;
@@ -1025,24 +1158,47 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1025
1158
  setSelected(0);
1026
1159
  }
1027
1160
  }, { isActive: mode === "search" });
1028
- if (mode === "add" && dataDir) {
1029
- const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1030
- return /* @__PURE__ */ jsx(AddEntryForm, {
1031
- apps: listApps(dataDir),
1032
- existingTags,
1033
- resolveTarget: (a) => resolveTargetFile(dataDir, a),
1034
- onSubmit: (app, entry) => addEntry(dataDir, app, entry),
1035
- onComplete: (result) => {
1036
- if (result.ok) {
1037
- reload();
1038
- setSelected(0);
1039
- setFlash(result.lines[0] ?? "✓ added");
1040
- }
1041
- setMode("search");
1042
- },
1043
- onCancel: () => setMode("search")
1044
- });
1045
- }
1161
+ const existingTags = [...new Set(entries.flatMap((e) => e.tags ?? []))].sort();
1162
+ if (mode === "add" && dataDir) return /* @__PURE__ */ jsx(AddEntryForm, {
1163
+ apps: listApps(dataDir),
1164
+ existingTags,
1165
+ resolveTarget: (a) => resolveTargetFile(dataDir, a),
1166
+ onSubmit: (app, entry) => addEntry(dataDir, app, entry),
1167
+ onComplete: (result) => {
1168
+ if (result.ok) {
1169
+ reload();
1170
+ setSelected(0);
1171
+ setFlash(result.lines[0] ?? "✓ added");
1172
+ }
1173
+ setMode("search");
1174
+ },
1175
+ onCancel: () => setMode("search")
1176
+ });
1177
+ if (mode === "edit" && dataDir && editTarget) return /* @__PURE__ */ jsx(AddEntryForm, {
1178
+ apps: listApps(dataDir),
1179
+ existingTags,
1180
+ lockedApp: editTarget.app,
1181
+ initial: entryToDraft(editTarget.app, editTarget),
1182
+ title: `Edit entry — ${editTarget.app}`,
1183
+ resolveTarget: () => ({
1184
+ file: editTarget.file,
1185
+ created: false
1186
+ }),
1187
+ onSubmit: (_app, entry) => editEntry(dataDir, editTarget.file, editTarget.index, entry, editTarget.action),
1188
+ onComplete: (result) => {
1189
+ if (result.ok) {
1190
+ reload();
1191
+ setSelected(0);
1192
+ setFlash(result.lines[0] ?? "✓ updated");
1193
+ }
1194
+ setMode("search");
1195
+ setEditTarget(null);
1196
+ },
1197
+ onCancel: () => {
1198
+ setMode("search");
1199
+ setEditTarget(null);
1200
+ }
1201
+ });
1046
1202
  return /* @__PURE__ */ jsxs(Box, {
1047
1203
  flexDirection: "column",
1048
1204
  children: [
@@ -1060,7 +1216,8 @@ function App({ entries: initial, errorCount = 0, dataDir, onCopy = copyToClipboa
1060
1216
  /* @__PURE__ */ jsx(Footer, {
1061
1217
  flash,
1062
1218
  errorCount,
1063
- resultCount: results.length
1219
+ resultCount: results.length,
1220
+ confirm: pendingDelete ? `Delete '${pendingDelete.app}: ${pendingDelete.action}'?` : void 0
1064
1221
  })
1065
1222
  ]
1066
1223
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthony/keybook",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/seed/vim.yaml CHANGED
@@ -64,3 +64,115 @@ entries:
64
64
  - action: Indent / outdent the line or selection
65
65
  keys: ">>, <<"
66
66
  tags: [indent]
67
+ - action: Move to end of word / end of WORD
68
+ keys: "e, E"
69
+ notes: e treats punctuation as a word boundary; E spans punctuation (whitespace-separated WORD).
70
+ tags: [word, motion, end]
71
+ - action: Move forward / back by WORD (whitespace-separated)
72
+ keys: "W, B"
73
+ notes: Capital variants of w / b — jump past punctuation in one step.
74
+ tags: [word, motion, big]
75
+ - action: Find a character on the current line
76
+ keys: "f, F, t, T"
77
+ notes: >-
78
+ After pressing, type the target character. f / F land ON it (forward / back);
79
+ t / T land just before it. ; repeats the same find, , reverses direction.
80
+ Searches like "fa" (find 'a') are great when paired with d / c (e.g. dt, deletes to before next ',').
81
+ tags: [find, char, line, motion, tip]
82
+ - action: Search forward / back for the word under the cursor
83
+ keys: "*, #"
84
+ notes: Use n / N afterwards to step through matches. Great for tracing usages.
85
+ tags: [search, word, find, navigate, tip]
86
+ - action: Jump to top / middle / bottom of the screen
87
+ keys: "H, M, L"
88
+ notes: Cursor only — doesn't scroll the viewport. Combine with zz to recenter after.
89
+ tags: [screen, viewport, motion]
90
+ - action: Scroll half / full page down / up
91
+ keys: "⌃D, ⌃U, ⌃F, ⌃B"
92
+ notes: Half-page (⌃D / ⌃U) is usually less jarring than full-page (⌃F / ⌃B).
93
+ tags: [scroll, page, motion]
94
+ - action: Recenter / top / bottom the current line in the viewport
95
+ keys: "zz, zt, zb"
96
+ notes: zz centers the current line, zt puts it at the top of the window, zb at the bottom.
97
+ tags: [scroll, center, recenter, viewport]
98
+ - action: Jump back / forward in the cursor history
99
+ keys: "⌃O, ⌃I"
100
+ notes: >-
101
+ Vim records every jump >1 line. ⌃O retraces backwards through your trail, ⌃I goes forward.
102
+ Indispensable for diving into code and bouncing back where you were.
103
+ tags: [jump, history, navigate, tip, productivity]
104
+ - action: Jump to local declaration / global definition under cursor
105
+ keys: "gd, gD"
106
+ notes: gd searches the current function/scope for the identifier under the cursor; gD searches the whole file.
107
+ tags: [definition, declaration, goto, tip]
108
+ - action: Open a new line below / above the current
109
+ keys: "o, O"
110
+ notes: Both enter Insert mode immediately on the new line.
111
+ tags: [insert, new line, edit]
112
+ - action: Insert at start / append at end of line
113
+ keys: "I, A"
114
+ notes: I jumps to the first non-blank character and enters Insert; A appends after the last character.
115
+ tags: [insert, append, line, edit]
116
+ - action: Change / delete to end of line
117
+ keys: "C, D"
118
+ notes: C is shorthand for c$ (change to end of line); D is d$ (delete to end). Quick rewrites.
119
+ tags: [change, delete, line, edit]
120
+ - action: Replace a single character / enter Replace mode
121
+ keys: "r, R"
122
+ notes: After r, type the replacement char (one key, doesn't enter Insert). R overwrites characters as you type until ⎋.
123
+ tags: [replace, overwrite, edit]
124
+ - action: Join the next line onto the current one
125
+ keys: "J"
126
+ notes: Adds a space between them. gJ joins without inserting a space.
127
+ tags: [join, line, edit]
128
+ - action: Toggle case of character / inner word
129
+ keys: "~, g~iw"
130
+ notes: ~ flips one char's case; g~iw flips the word under the cursor. gu / gU make a motion lower / upper-case.
131
+ tags: [case, toggle, edit]
132
+ - action: Split the window horizontally / vertically
133
+ keys: ":split⏎, :vsplit⏎"
134
+ notes: Short forms :sp and :vsp. After splitting, :e <file> opens a file in the focused split.
135
+ tags: [split, window, layout]
136
+ - action: Move focus between splits
137
+ keys: "⌃Wh, ⌃Wj, ⌃Wk, ⌃Wl"
138
+ notes: ⌃W followed by an hjkl direction. ⌃W⌃W cycles forward through splits.
139
+ tags: [split, window, navigate]
140
+ - action: List and switch buffers
141
+ keys: ":ls⏎, :b <n>⏎, :bn, :bp"
142
+ notes: :ls lists all open buffers with their numbers. :bn / :bp cycle next / previous.
143
+ tags: [buffer, switch, list, tip]
144
+ - action: Close the current buffer (keep the split layout)
145
+ keys: ":bd⏎"
146
+ notes: Drops the buffer but keeps the window. Add ! to discard unsaved changes.
147
+ tags: [buffer, close]
148
+ - action: Set a mark / jump back to it
149
+ keys: "m, ', `"
150
+ notes: >-
151
+ m{letter} sets a mark at the cursor; '{letter} jumps to its line, `{letter} (backtick) to the exact column.
152
+ Lowercase marks a–z are file-local; capital A–Z are global across files.
153
+ tags: [mark, jump, bookmark, "ma", "'a", tip]
154
+ - action: Record / play back a macro
155
+ keys: "q...q, @, @@"
156
+ notes: >-
157
+ q{letter} starts recording into a register; q stops. @{letter} replays. @@ re-runs the last macro.
158
+ Workflow — record one edit on the first item, then @a (or whatever register) on each subsequent occurrence.
159
+ tags: [macro, record, replay, automation, tip, productivity]
160
+ - action: Repeat the last ex (:) command
161
+ keys: "@:"
162
+ notes: "After running any :command once, @: replays it; @@ then keeps replaying."
163
+ tags: [repeat, command, tip]
164
+ - action: Toggle relative / absolute line numbers
165
+ keys: ":set rnu⏎, :set nornu⏎, :set nu⏎"
166
+ notes: >-
167
+ Relative numbers (rnu) show each line's distance from the cursor — "5j" or "12dd" become trivial to count.
168
+ Combine `set nu` + `set rnu` for a hybrid view. Put both in your vimrc to make it permanent.
169
+ tags: [option, line numbers, relative, productivity, tip]
170
+ - action: Insert the same text on every line of a column selection
171
+ steps:
172
+ - "⌃V to enter Visual-Block mode"
173
+ - Use j / k to extend the block over the lines you want
174
+ - Press I (capital) to insert at the LEFT edge of the block
175
+ - Type the text you want repeated on every line
176
+ - "Press ⎋ — wait a moment — the text duplicates onto every selected line"
177
+ notes: A (capital) does the same at the RIGHT edge. Game-changer for adding comment markers, trailing commas, or any column prefix/suffix.
178
+ tags: [visual block, multi line, insert, comment, prefix, tip, productivity]