@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 +3 -2
- package/dist/cli.js +196 -39
- package/package.json +1 -1
- package/seed/vim.yaml +112 -0
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
|
-
|
|
46
|
-
|
|
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
|
-
}),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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__ */
|
|
846
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
758
847
|
marginTop: 1,
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
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]
|