ep_vim 0.9.3 → 0.10.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
@@ -10,15 +10,16 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
10
10
 
11
11
  ## Features
12
12
 
13
- - **Modal editing** — normal, insert, and visual (char + line) modes
13
+ - **Modal editing** — normal, insert, and visual (char + line) modes; visual selections support all operators (`d`, `c`, `y`)
14
14
  - **Motions** — `h` `j` `k` `l`, `w` `b` `e`, `0` `$` `^`, `gg` `G`, `f`/`F`/`t`/`T` char search, `{` `}` paragraph forward/backward, `H` `M` `L` viewport (top/middle/bottom)
15
15
  - **Char search** — `f`/`F`/`t`/`T` find, `;` repeat last search, `,` reverse direction
16
16
  - **Bracket matching** — `%` jump to matching bracket
17
17
  - **Text objects** — `iw`/`aw` (word), `i"`/`a"` and `i'`/`a'` (quotes), `i{`/`a{` etc. (brackets), `ip`/`ap` (paragraph), `is`/`as` (sentence)
18
18
  - **Operators** — `d`, `c`, `y` with motion and text object combinations (`dw`, `ce`, `y$`, `ciw`, `da"`, `yi(`, etc.)
19
19
  - **Line operations** — `dd`, `cc`, `yy`, `J` (join), `Y` (yank line)
20
+ - **Registers** — `"a`–`"z` named registers for yank/delete/put, `"_` blackhole register
20
21
  - **Put** — `p` / `P` with linewise and characterwise register handling
21
- - **Editing** — `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
22
+ - **Editing** — `i` `a` `A` `I` (insert/append), `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
22
23
  - **Marks** — `m{a-z}` to set, `'{a-z}` / `` `{a-z} `` to jump
23
24
  - **Search** — `/` and `?` forward/backward, `n`/`N` repeat
24
25
  - **Repeat** — `.` repeat last command
@@ -26,12 +27,18 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
26
27
  - **Undo** — `u`
27
28
  - **Toggle** — toolbar button to enable/disable vim mode, persisted in localStorage
28
29
 
30
+ ## Differences from vi
31
+
32
+ - **Ctrl key mapping** — browser shortcuts conflict with common vim bindings (`Ctrl+d`, `Ctrl+u`, `Ctrl+r`, etc.); These will be implemented under a configurable setting.
33
+ - **Clipboard toggle** — the default register writes to the system clipboard. We will add a setting to turn on the default vim behavior.
34
+ - **No command line, macros, or globals** - these are not planned, but PRs welcome.
35
+
29
36
  ## Installation
30
37
 
31
- Clone or symlink into your Etherpad plugins directory, then install:
38
+ From your Etherpad directory run
32
39
 
33
40
  ```
34
- pnpm install ep_vim
41
+ pnpm run plugins install ep_vim
35
42
  ```
36
43
 
37
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_vim",
3
- "version": "0.9.3",
3
+ "version": "0.10.1",
4
4
  "description": "Vim-mode plugin for Etherpad with modal editing, motions, and operators",
5
5
  "author": {
6
6
  "name": "Seth Rothschild",
@@ -24,14 +24,14 @@
24
24
  "peerDependencies": {
25
25
  "ep_etherpad-lite": ">=1.8.6"
26
26
  },
27
- "scripts": {
28
- "test": "npm run format && node --test static/js/vim-core.test.js static/js/index.test.js",
29
- "format": "prettier -w static/js/*"
30
- },
31
27
  "engines": {
32
28
  "node": ">=20.0.0"
33
29
  },
34
30
  "dependencies": {
35
31
  "prettier": "^3.8.1"
32
+ },
33
+ "scripts": {
34
+ "test": "npm run format && node --test static/js/vim-core.test.js static/js/index.test.js",
35
+ "format": "prettier -w static/js/*"
36
36
  }
37
- }
37
+ }
@@ -36,6 +36,9 @@ const state = {
36
36
  pendingCount: null,
37
37
  countBuffer: "",
38
38
  register: null,
39
+ namedRegisters: {},
40
+ pendingRegister: null,
41
+ awaitingRegister: false,
39
42
  marks: {},
40
43
  lastCharSearch: null,
41
44
  visualAnchor: null,
@@ -53,6 +56,14 @@ const state = {
53
56
  // --- Editor operations ---
54
57
 
55
58
  const setRegister = (value) => {
59
+ if (state.pendingRegister === "_") {
60
+ return;
61
+ }
62
+ if (state.pendingRegister && /^[a-zA-Z]$/.test(state.pendingRegister)) {
63
+ const name = state.pendingRegister.toLowerCase();
64
+ state.namedRegisters[name] = value;
65
+ return;
66
+ }
56
67
  state.register = value;
57
68
  const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
58
69
  if (navigator.clipboard) {
@@ -60,6 +71,19 @@ const setRegister = (value) => {
60
71
  }
61
72
  };
62
73
 
74
+ const getActiveRegister = () => {
75
+ if (state.pendingRegister === "_") {
76
+ return null;
77
+ }
78
+ if (state.pendingRegister && /^[a-zA-Z]$/.test(state.pendingRegister)) {
79
+ const name = state.pendingRegister.toLowerCase();
80
+ return state.namedRegisters[name] !== undefined
81
+ ? state.namedRegisters[name]
82
+ : null;
83
+ }
84
+ return state.register;
85
+ };
86
+
63
87
  const moveCursor = (editorInfo, line, char) => {
64
88
  const pos = [line, char];
65
89
  editorInfo.ace_inCallStackIfNecessary("vim-move", () => {
@@ -833,6 +857,7 @@ parameterized["r"] = (key, { editorInfo, line, char, lineText, count }) => {
833
857
 
834
858
  commands.normal["Y"] = ({ rep, line }) => {
835
859
  setRegister([getLineText(rep, line)]);
860
+ recordCommand("Y", 1);
836
861
  };
837
862
 
838
863
  commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
@@ -847,14 +872,15 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
847
872
  };
848
873
 
849
874
  commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
850
- if (state.register !== null) {
851
- if (typeof state.register === "string") {
875
+ const reg = getActiveRegister();
876
+ if (reg !== null) {
877
+ if (typeof reg === "string") {
852
878
  const insertPos = Math.min(char + 1, lineText.length);
853
- const repeated = state.register.repeat(count);
879
+ const repeated = reg.repeat(count);
854
880
  replaceRange(editorInfo, [line, insertPos], [line, insertPos], repeated);
855
- moveBlockCursor(editorInfo, line, insertPos);
881
+ moveBlockCursor(editorInfo, line, insertPos + repeated.length - 1);
856
882
  } else {
857
- const block = state.register.join("\n");
883
+ const block = reg.join("\n");
858
884
  const parts = [];
859
885
  for (let i = 0; i < count; i++) parts.push(block);
860
886
  const insertText = "\n" + parts.join("\n");
@@ -864,25 +890,26 @@ commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
864
890
  [line, lineText.length],
865
891
  insertText,
866
892
  );
867
- moveBlockCursor(editorInfo, line + 1, 0);
893
+ moveBlockCursor(editorInfo, line + 1, firstNonBlank(reg[0]));
868
894
  }
869
895
  recordCommand("p", count);
870
896
  }
871
897
  };
872
898
 
873
899
  commands.normal["P"] = ({ editorInfo, line, char, count }) => {
874
- if (state.register !== null) {
875
- if (typeof state.register === "string") {
876
- const repeated = state.register.repeat(count);
900
+ const reg = getActiveRegister();
901
+ if (reg !== null) {
902
+ if (typeof reg === "string") {
903
+ const repeated = reg.repeat(count);
877
904
  replaceRange(editorInfo, [line, char], [line, char], repeated);
878
- moveBlockCursor(editorInfo, line, char);
905
+ moveBlockCursor(editorInfo, line, char + repeated.length - 1);
879
906
  } else {
880
- const block = state.register.join("\n");
907
+ const block = reg.join("\n");
881
908
  const parts = [];
882
909
  for (let i = 0; i < count; i++) parts.push(block);
883
910
  const insertText = parts.join("\n") + "\n";
884
911
  replaceRange(editorInfo, [line, 0], [line, 0], insertText);
885
- moveBlockCursor(editorInfo, line, 0);
912
+ moveBlockCursor(editorInfo, line, firstNonBlank(reg[0]));
886
913
  }
887
914
  recordCommand("P", count);
888
915
  }
@@ -944,7 +971,7 @@ commands.normal["C"] = ({ editorInfo, line, char, lineText }) => {
944
971
 
945
972
  commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
946
973
  clearEmptyLineCursor();
947
- setRegister(lineText.slice(char, char + 1));
974
+ setRegister(lineText.slice(char, Math.min(char + count, lineText.length)));
948
975
  replaceRange(
949
976
  editorInfo,
950
977
  [line, char],
@@ -958,7 +985,7 @@ commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
958
985
 
959
986
  commands.normal["S"] = ({ editorInfo, line, lineText }) => {
960
987
  clearEmptyLineCursor();
961
- setRegister(lineText);
988
+ setRegister([lineText]);
962
989
  replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
963
990
  moveCursor(editorInfo, line, 0);
964
991
  state.mode = "insert";
@@ -995,9 +1022,50 @@ commands.normal["N"] = (ctx) => {
995
1022
  if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
996
1023
  };
997
1024
 
1025
+ const getWordAt = (text, char) => {
1026
+ if (char >= text.length || !/\w/.test(text[char])) return null;
1027
+ let start = char;
1028
+ while (start > 0 && /\w/.test(text[start - 1])) start--;
1029
+ let end = char;
1030
+ while (end < text.length && /\w/.test(text[end])) end++;
1031
+ return text.slice(start, end);
1032
+ };
1033
+
1034
+ commands.normal["*"] = (ctx) => {
1035
+ const word = getWordAt(ctx.lineText, ctx.char);
1036
+ if (!word) return;
1037
+ state.lastSearch = { pattern: word, direction: "/" };
1038
+ const pos = searchForward(ctx.rep, ctx.line, ctx.char + 1, word, ctx.count);
1039
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1040
+ };
1041
+
1042
+ commands.normal["#"] = (ctx) => {
1043
+ const word = getWordAt(ctx.lineText, ctx.char);
1044
+ if (!word) return;
1045
+ state.lastSearch = { pattern: word, direction: "?" };
1046
+ const pos = searchBackward(ctx.rep, ctx.line, ctx.char, word, ctx.count);
1047
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1048
+ };
1049
+
1050
+ commands.normal["zz"] = ({ line }) => {
1051
+ if (!state.editorDoc) return;
1052
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1053
+ if (lineDiv) lineDiv.scrollIntoView({ block: "center" });
1054
+ };
1055
+
998
1056
  // --- Dispatch ---
999
1057
 
1000
1058
  const handleKey = (key, ctx) => {
1059
+ if (state.awaitingRegister) {
1060
+ state.pendingRegister = key;
1061
+ state.awaitingRegister = false;
1062
+ return true;
1063
+ }
1064
+ if (key === '"' && state.mode === "normal") {
1065
+ state.awaitingRegister = true;
1066
+ return true;
1067
+ }
1068
+
1001
1069
  if (key >= "1" && key <= "9") {
1002
1070
  state.countBuffer += key;
1003
1071
  return true;
@@ -1019,6 +1087,7 @@ const handleKey = (key, ctx) => {
1019
1087
  state.pendingKey = null;
1020
1088
  handler(key, ctx);
1021
1089
  state.pendingCount = null;
1090
+ state.pendingRegister = null;
1022
1091
  return true;
1023
1092
  }
1024
1093
 
@@ -1028,7 +1097,10 @@ const handleKey = (key, ctx) => {
1028
1097
  if (map[seq]) {
1029
1098
  state.pendingKey = null;
1030
1099
  map[seq](ctx);
1031
- if (state.pendingKey === null) state.pendingCount = null;
1100
+ if (state.pendingKey === null) {
1101
+ state.pendingCount = null;
1102
+ state.pendingRegister = null;
1103
+ }
1032
1104
  return true;
1033
1105
  }
1034
1106
 
@@ -1073,6 +1145,7 @@ const handleKey = (key, ctx) => {
1073
1145
 
1074
1146
  state.pendingKey = null;
1075
1147
  state.pendingCount = null;
1148
+ state.pendingRegister = null;
1076
1149
  return true;
1077
1150
  };
1078
1151
 
@@ -2007,6 +2007,9 @@ const resetState = () => {
2007
2007
  state.pendingCount = null;
2008
2008
  state.countBuffer = "";
2009
2009
  state.register = null;
2010
+ state.namedRegisters = {};
2011
+ state.pendingRegister = null;
2012
+ state.awaitingRegister = false;
2010
2013
  state.marks = {};
2011
2014
  state.lastCharSearch = null;
2012
2015
  state.visualAnchor = null;
@@ -3079,3 +3082,651 @@ describe("edge cases: df and dt (operator + char motion)", () => {
3079
3082
  );
3080
3083
  });
3081
3084
  });
3085
+
3086
+ // --- Register bugs ---
3087
+
3088
+ describe("register bug: s ignores count", () => {
3089
+ beforeEach(resetState);
3090
+
3091
+ it("s with count=3 saves 3 chars to register", () => {
3092
+ const rep = makeRep(["abcdef"]);
3093
+ const { editorInfo } = makeMockEditorInfo();
3094
+
3095
+ const ctx = {
3096
+ rep,
3097
+ editorInfo,
3098
+ line: 0,
3099
+ char: 1,
3100
+ lineText: "abcdef",
3101
+ count: 3,
3102
+ };
3103
+ commands.normal["s"](ctx);
3104
+
3105
+ assert.equal(state.register, "bcd", "3s should save 3 chars, not 1");
3106
+ });
3107
+
3108
+ it("s with count=1 saves 1 char to register", () => {
3109
+ const rep = makeRep(["abcdef"]);
3110
+ const { editorInfo } = makeMockEditorInfo();
3111
+
3112
+ const ctx = {
3113
+ rep,
3114
+ editorInfo,
3115
+ line: 0,
3116
+ char: 2,
3117
+ lineText: "abcdef",
3118
+ count: 1,
3119
+ };
3120
+ commands.normal["s"](ctx);
3121
+
3122
+ assert.equal(state.register, "c");
3123
+ });
3124
+
3125
+ it("s with count exceeding line saves up to end of line", () => {
3126
+ const rep = makeRep(["abc"]);
3127
+ const { editorInfo } = makeMockEditorInfo();
3128
+
3129
+ const ctx = {
3130
+ rep,
3131
+ editorInfo,
3132
+ line: 0,
3133
+ char: 1,
3134
+ lineText: "abc",
3135
+ count: 10,
3136
+ };
3137
+ commands.normal["s"](ctx);
3138
+
3139
+ assert.equal(
3140
+ state.register,
3141
+ "bc",
3142
+ "s with large count should save remaining chars",
3143
+ );
3144
+ });
3145
+ });
3146
+
3147
+ describe("register bug: S uses char-wise register instead of line-wise", () => {
3148
+ beforeEach(resetState);
3149
+
3150
+ it("S saves register as array (line-wise)", () => {
3151
+ const rep = makeRep(["hello world"]);
3152
+ const { editorInfo } = makeMockEditorInfo();
3153
+
3154
+ const ctx = {
3155
+ rep,
3156
+ editorInfo,
3157
+ line: 0,
3158
+ char: 0,
3159
+ lineText: "hello world",
3160
+ count: 1,
3161
+ };
3162
+ commands.normal["S"](ctx);
3163
+
3164
+ assert.ok(
3165
+ Array.isArray(state.register),
3166
+ "S should store register as array (line-wise), not string",
3167
+ );
3168
+ assert.deepEqual(state.register, ["hello world"]);
3169
+ });
3170
+
3171
+ it("S register is line-wise so p pastes on new line", () => {
3172
+ const rep = makeRep(["hello"]);
3173
+ const { editorInfo, calls } = makeMockEditorInfo();
3174
+
3175
+ const ctx = {
3176
+ rep,
3177
+ editorInfo,
3178
+ line: 0,
3179
+ char: 0,
3180
+ lineText: "hello",
3181
+ count: 1,
3182
+ };
3183
+ commands.normal["S"](ctx);
3184
+
3185
+ assert.ok(
3186
+ Array.isArray(state.register),
3187
+ "register should be line-wise after S",
3188
+ );
3189
+
3190
+ const replaces = calls.filter((c) => c.type === "replace");
3191
+ assert.ok(replaces.length > 0);
3192
+ assert.equal(replaces[0].newText, "", "S should clear the line");
3193
+ });
3194
+ });
3195
+
3196
+ describe("register bug: p cursor at start of paste instead of end", () => {
3197
+ beforeEach(resetState);
3198
+
3199
+ it("p char-wise: cursor lands at last char of pasted text", () => {
3200
+ const rep = makeRep(["hello"]);
3201
+ const { editorInfo, calls } = makeMockEditorInfo();
3202
+
3203
+ state.register = "abc";
3204
+ const ctx = {
3205
+ rep,
3206
+ editorInfo,
3207
+ line: 0,
3208
+ char: 0,
3209
+ lineText: "hello",
3210
+ count: 1,
3211
+ };
3212
+ commands.normal["p"](ctx);
3213
+
3214
+ const selects = calls.filter((c) => c.type === "select");
3215
+ const lastSelect = selects[selects.length - 1];
3216
+ assert.deepEqual(
3217
+ lastSelect.start,
3218
+ [0, 3],
3219
+ "cursor after p should be at last char of pasted text (insertPos + length - 1 = 1+3-1 = 3)",
3220
+ );
3221
+ });
3222
+
3223
+ it("p char-wise with count: cursor lands at last char of all pasted text", () => {
3224
+ const rep = makeRep(["hello"]);
3225
+ const { editorInfo, calls } = makeMockEditorInfo();
3226
+
3227
+ state.register = "ab";
3228
+ const ctx = {
3229
+ rep,
3230
+ editorInfo,
3231
+ line: 0,
3232
+ char: 0,
3233
+ lineText: "hello",
3234
+ count: 3,
3235
+ };
3236
+ commands.normal["p"](ctx);
3237
+
3238
+ const selects = calls.filter((c) => c.type === "select");
3239
+ const lastSelect = selects[selects.length - 1];
3240
+ assert.deepEqual(
3241
+ lastSelect.start,
3242
+ [0, 6],
3243
+ "cursor after 3p 'ab': insertPos=1, repeated='ababab' length=6, cursor=1+6-1=6",
3244
+ );
3245
+ });
3246
+ });
3247
+
3248
+ describe("register bug: P cursor at start of paste instead of end", () => {
3249
+ beforeEach(resetState);
3250
+
3251
+ it("P char-wise: cursor lands at last char of pasted text", () => {
3252
+ const rep = makeRep(["hello"]);
3253
+ const { editorInfo, calls } = makeMockEditorInfo();
3254
+
3255
+ state.register = "abc";
3256
+ const ctx = {
3257
+ rep,
3258
+ editorInfo,
3259
+ line: 0,
3260
+ char: 2,
3261
+ lineText: "hello",
3262
+ count: 1,
3263
+ };
3264
+ commands.normal["P"](ctx);
3265
+
3266
+ const selects = calls.filter((c) => c.type === "select");
3267
+ const lastSelect = selects[selects.length - 1];
3268
+ assert.deepEqual(
3269
+ lastSelect.start,
3270
+ [0, 4],
3271
+ "cursor after P should be at last char of pasted text (char + length - 1 = 2+3-1 = 4)",
3272
+ );
3273
+ });
3274
+
3275
+ it("P char-wise with count: cursor lands at last char of all pasted text", () => {
3276
+ const rep = makeRep(["hello"]);
3277
+ const { editorInfo, calls } = makeMockEditorInfo();
3278
+
3279
+ state.register = "ab";
3280
+ const ctx = {
3281
+ rep,
3282
+ editorInfo,
3283
+ line: 0,
3284
+ char: 1,
3285
+ lineText: "hello",
3286
+ count: 2,
3287
+ };
3288
+ commands.normal["P"](ctx);
3289
+
3290
+ const selects = calls.filter((c) => c.type === "select");
3291
+ const lastSelect = selects[selects.length - 1];
3292
+ assert.deepEqual(
3293
+ lastSelect.start,
3294
+ [0, 4],
3295
+ "cursor after 2P 'ab' should be at char + 4 - 1 = 1+4-1 = 4",
3296
+ );
3297
+ });
3298
+ });
3299
+
3300
+ describe("register bug: line-wise p cursor at col 0 instead of first non-blank", () => {
3301
+ beforeEach(resetState);
3302
+
3303
+ it("p line-wise: cursor lands on first non-blank of pasted line", () => {
3304
+ const rep = makeRep(["hello", "world"]);
3305
+ const { editorInfo, calls } = makeMockEditorInfo();
3306
+
3307
+ state.register = [" indented"];
3308
+ const ctx = {
3309
+ rep,
3310
+ editorInfo,
3311
+ line: 0,
3312
+ char: 0,
3313
+ lineText: "hello",
3314
+ count: 1,
3315
+ };
3316
+ commands.normal["p"](ctx);
3317
+
3318
+ const selects = calls.filter((c) => c.type === "select");
3319
+ const lastSelect = selects[selects.length - 1];
3320
+ assert.deepEqual(
3321
+ lastSelect.start,
3322
+ [1, 2],
3323
+ "p line-wise cursor should be at first non-blank (col 2) of pasted line",
3324
+ );
3325
+ });
3326
+
3327
+ it("p line-wise with unindented content: cursor at col 0", () => {
3328
+ const rep = makeRep(["hello", "world"]);
3329
+ const { editorInfo, calls } = makeMockEditorInfo();
3330
+
3331
+ state.register = ["noindent"];
3332
+ const ctx = {
3333
+ rep,
3334
+ editorInfo,
3335
+ line: 0,
3336
+ char: 0,
3337
+ lineText: "hello",
3338
+ count: 1,
3339
+ };
3340
+ commands.normal["p"](ctx);
3341
+
3342
+ const selects = calls.filter((c) => c.type === "select");
3343
+ const lastSelect = selects[selects.length - 1];
3344
+ assert.deepEqual(lastSelect.start, [1, 0]);
3345
+ });
3346
+ });
3347
+
3348
+ describe("register bug: line-wise P cursor at col 0 instead of first non-blank", () => {
3349
+ beforeEach(resetState);
3350
+
3351
+ it("P line-wise: cursor lands on first non-blank of pasted line", () => {
3352
+ const rep = makeRep(["hello", "world"]);
3353
+ const { editorInfo, calls } = makeMockEditorInfo();
3354
+
3355
+ state.register = [" indented"];
3356
+ const ctx = {
3357
+ rep,
3358
+ editorInfo,
3359
+ line: 1,
3360
+ char: 0,
3361
+ lineText: "world",
3362
+ count: 1,
3363
+ };
3364
+ commands.normal["P"](ctx);
3365
+
3366
+ const selects = calls.filter((c) => c.type === "select");
3367
+ const lastSelect = selects[selects.length - 1];
3368
+ assert.deepEqual(
3369
+ lastSelect.start,
3370
+ [1, 2],
3371
+ "P line-wise cursor should be at first non-blank (col 2) of pasted line",
3372
+ );
3373
+ });
3374
+ });
3375
+
3376
+ describe("register bug: Y missing recordCommand", () => {
3377
+ beforeEach(resetState);
3378
+
3379
+ it("Y sets lastCommand so dot can repeat it", () => {
3380
+ const rep = makeRep(["hello"]);
3381
+ const { editorInfo } = makeMockEditorInfo();
3382
+
3383
+ const ctx = {
3384
+ rep,
3385
+ editorInfo,
3386
+ line: 0,
3387
+ char: 0,
3388
+ lineText: "hello",
3389
+ count: 1,
3390
+ };
3391
+ commands.normal["Y"](ctx);
3392
+
3393
+ assert.notEqual(
3394
+ state.lastCommand,
3395
+ null,
3396
+ "Y should record command for dot-repeat",
3397
+ );
3398
+ assert.equal(state.lastCommand.key, "Y");
3399
+ });
3400
+
3401
+ it("Y does not corrupt lastCommand of a prior operation", () => {
3402
+ const rep = makeRep(["hello"]);
3403
+ const { editorInfo } = makeMockEditorInfo();
3404
+
3405
+ state.lastCommand = { key: "dd", count: 1, param: null };
3406
+ const ctx = {
3407
+ rep,
3408
+ editorInfo,
3409
+ line: 0,
3410
+ char: 0,
3411
+ lineText: "hello",
3412
+ count: 1,
3413
+ };
3414
+ commands.normal["Y"](ctx);
3415
+
3416
+ assert.equal(
3417
+ state.lastCommand.key,
3418
+ "Y",
3419
+ "Y should overwrite lastCommand with its own entry",
3420
+ );
3421
+ });
3422
+ });
3423
+
3424
+ describe("missing feature: named registers", () => {
3425
+ beforeEach(resetState);
3426
+
3427
+ it('"ayy yanks into named register a', () => {
3428
+ // In vim, "a followed by yy copies the line into register 'a'.
3429
+ // This requires parsing the " prefix in handleKey and routing
3430
+ // setRegister calls to the named register slot.
3431
+ const rep = makeRep(["hello"]);
3432
+ const { editorInfo } = makeMockEditorInfo();
3433
+ const ctx = {
3434
+ rep,
3435
+ editorInfo,
3436
+ line: 0,
3437
+ char: 0,
3438
+ lineText: "hello",
3439
+ count: 1,
3440
+ hasCount: false,
3441
+ };
3442
+
3443
+ // Simulate: " -> a -> yy
3444
+ handleKey('"', ctx);
3445
+ handleKey("a", ctx);
3446
+ handleKey("y", ctx);
3447
+ handleKey("y", ctx);
3448
+
3449
+ assert.ok(
3450
+ state.namedRegisters && state.namedRegisters["a"],
3451
+ "register a should be set",
3452
+ );
3453
+ assert.deepEqual(state.namedRegisters["a"], ["hello"]);
3454
+ });
3455
+
3456
+ it('"ap pastes from named register a', () => {
3457
+ state.namedRegisters = { a: ["yanked line"] };
3458
+ const rep = makeRep(["hello"]);
3459
+ const { editorInfo, calls } = makeMockEditorInfo();
3460
+ const ctx = {
3461
+ rep,
3462
+ editorInfo,
3463
+ line: 0,
3464
+ char: 0,
3465
+ lineText: "hello",
3466
+ count: 1,
3467
+ hasCount: false,
3468
+ };
3469
+
3470
+ // Simulate: " -> a -> p
3471
+ handleKey('"', ctx);
3472
+ handleKey("a", ctx);
3473
+ handleKey("p", ctx);
3474
+
3475
+ const replaces = calls.filter((c) => c.type === "replace");
3476
+ assert.ok(replaces.length > 0, "should paste from named register");
3477
+ });
3478
+
3479
+ it('"add deletes line into named register a', () => {
3480
+ const rep = makeRep(["hello", "world"]);
3481
+ const { editorInfo } = makeMockEditorInfo();
3482
+ const ctx = {
3483
+ rep,
3484
+ editorInfo,
3485
+ line: 0,
3486
+ char: 0,
3487
+ lineText: "hello",
3488
+ count: 1,
3489
+ hasCount: false,
3490
+ };
3491
+
3492
+ handleKey('"', ctx);
3493
+ handleKey("a", ctx);
3494
+ handleKey("d", ctx);
3495
+ handleKey("d", ctx);
3496
+
3497
+ assert.ok(
3498
+ state.namedRegisters && state.namedRegisters["a"],
3499
+ "register a should be set after delete",
3500
+ );
3501
+ assert.deepEqual(state.namedRegisters["a"], ["hello"]);
3502
+ assert.equal(state.register, null, "anonymous register should not be set");
3503
+ });
3504
+
3505
+ it('"ayw yanks word into named register a', () => {
3506
+ const rep = makeRep(["hello world"]);
3507
+ const { editorInfo } = makeMockEditorInfo();
3508
+ const ctx = {
3509
+ rep,
3510
+ editorInfo,
3511
+ line: 0,
3512
+ char: 0,
3513
+ lineText: "hello world",
3514
+ count: 1,
3515
+ hasCount: false,
3516
+ };
3517
+
3518
+ handleKey('"', ctx);
3519
+ handleKey("a", ctx);
3520
+ handleKey("y", ctx);
3521
+ handleKey("w", ctx);
3522
+
3523
+ assert.deepEqual(state.namedRegisters["a"], "hello ");
3524
+ });
3525
+
3526
+ it("named registers are independent", () => {
3527
+ const rep = makeRep(["first", "second"]);
3528
+ const { editorInfo } = makeMockEditorInfo();
3529
+
3530
+ const ctx0 = {
3531
+ rep,
3532
+ editorInfo,
3533
+ line: 0,
3534
+ char: 0,
3535
+ lineText: "first",
3536
+ count: 1,
3537
+ hasCount: false,
3538
+ };
3539
+ handleKey('"', ctx0);
3540
+ handleKey("a", ctx0);
3541
+ handleKey("y", ctx0);
3542
+ handleKey("y", ctx0);
3543
+
3544
+ const ctx1 = {
3545
+ rep,
3546
+ editorInfo,
3547
+ line: 1,
3548
+ char: 0,
3549
+ lineText: "second",
3550
+ count: 1,
3551
+ hasCount: false,
3552
+ };
3553
+ handleKey('"', ctx1);
3554
+ handleKey("b", ctx1);
3555
+ handleKey("y", ctx1);
3556
+ handleKey("y", ctx1);
3557
+
3558
+ assert.deepEqual(state.namedRegisters["a"], ["first"]);
3559
+ assert.deepEqual(state.namedRegisters["b"], ["second"]);
3560
+ });
3561
+
3562
+ it('"_yy yank to blackhole does not affect anonymous register', () => {
3563
+ state.register = "preserved";
3564
+ const rep = makeRep(["hello"]);
3565
+ const { editorInfo } = makeMockEditorInfo();
3566
+ const ctx = {
3567
+ rep,
3568
+ editorInfo,
3569
+ line: 0,
3570
+ char: 0,
3571
+ lineText: "hello",
3572
+ count: 1,
3573
+ hasCount: false,
3574
+ };
3575
+
3576
+ handleKey('"', ctx);
3577
+ handleKey("_", ctx);
3578
+ handleKey("y", ctx);
3579
+ handleKey("y", ctx);
3580
+
3581
+ assert.equal(state.register, "preserved", "anonymous register unchanged");
3582
+ });
3583
+
3584
+ it('"_p pastes nothing from blackhole register', () => {
3585
+ state.register = "fallback";
3586
+ const rep = makeRep(["hello"]);
3587
+ const { editorInfo, calls } = makeMockEditorInfo();
3588
+ const ctx = {
3589
+ rep,
3590
+ editorInfo,
3591
+ line: 0,
3592
+ char: 0,
3593
+ lineText: "hello",
3594
+ count: 1,
3595
+ hasCount: false,
3596
+ };
3597
+
3598
+ handleKey('"', ctx);
3599
+ handleKey("_", ctx);
3600
+ handleKey("p", ctx);
3601
+
3602
+ const replaces = calls.filter((c) => c.type === "replace");
3603
+ assert.equal(replaces.length, 0, "blackhole p should paste nothing");
3604
+ });
3605
+
3606
+ it('"_dd deletes to blackhole register without overwriting anonymous', () => {
3607
+ state.register = "preserved";
3608
+ const rep = makeRep(["hello", "world"]);
3609
+ const { editorInfo } = makeMockEditorInfo();
3610
+ const ctx = {
3611
+ rep,
3612
+ editorInfo,
3613
+ line: 0,
3614
+ char: 0,
3615
+ lineText: "hello",
3616
+ count: 1,
3617
+ hasCount: false,
3618
+ };
3619
+
3620
+ // Simulate: " -> _ -> dd
3621
+ handleKey('"', ctx);
3622
+ handleKey("_", ctx);
3623
+ handleKey("d", ctx);
3624
+ handleKey("d", ctx);
3625
+
3626
+ assert.equal(
3627
+ state.register,
3628
+ "preserved",
3629
+ "blackhole register should not overwrite anonymous register",
3630
+ );
3631
+ });
3632
+ });
3633
+
3634
+ describe("missing feature: * and # word search", () => {
3635
+ beforeEach(resetState);
3636
+
3637
+ it("* searches forward for word under cursor", () => {
3638
+ const rep = makeRep(["hello world hello"]);
3639
+ const { editorInfo, calls } = makeMockEditorInfo();
3640
+ const ctx = {
3641
+ rep,
3642
+ editorInfo,
3643
+ line: 0,
3644
+ char: 0,
3645
+ lineText: "hello world hello",
3646
+ count: 1,
3647
+ hasCount: false,
3648
+ };
3649
+
3650
+ commands.normal["*"](ctx);
3651
+
3652
+ assert.equal(calls.length, 1, "should move cursor to next match");
3653
+ assert.deepEqual(state.lastSearch, { pattern: "hello", direction: "/" });
3654
+ });
3655
+
3656
+ it("* on non-word char does nothing", () => {
3657
+ const rep = makeRep(["hello world"]);
3658
+ const { editorInfo, calls } = makeMockEditorInfo();
3659
+ const ctx = {
3660
+ rep,
3661
+ editorInfo,
3662
+ line: 0,
3663
+ char: 5,
3664
+ lineText: "hello world",
3665
+ count: 1,
3666
+ hasCount: false,
3667
+ };
3668
+
3669
+ commands.normal["*"](ctx);
3670
+
3671
+ assert.equal(calls.length, 0, "should not move cursor");
3672
+ assert.equal(state.lastSearch, null);
3673
+ });
3674
+
3675
+ it("# searches backward for word under cursor", () => {
3676
+ const rep = makeRep(["hello world hello"]);
3677
+ const { editorInfo, calls } = makeMockEditorInfo();
3678
+ const ctx = {
3679
+ rep,
3680
+ editorInfo,
3681
+ line: 0,
3682
+ char: 12,
3683
+ lineText: "hello world hello",
3684
+ count: 1,
3685
+ hasCount: false,
3686
+ };
3687
+
3688
+ commands.normal["#"](ctx);
3689
+
3690
+ assert.equal(calls.length, 1, "should move cursor to previous match");
3691
+ assert.deepEqual(state.lastSearch, { pattern: "hello", direction: "?" });
3692
+ });
3693
+
3694
+ it("* sets lastSearch so n repeats the search", () => {
3695
+ const rep = makeRep(["foo bar foo bar"]);
3696
+ const { editorInfo } = makeMockEditorInfo();
3697
+ const ctx = {
3698
+ rep,
3699
+ editorInfo,
3700
+ line: 0,
3701
+ char: 0,
3702
+ lineText: "foo bar foo bar",
3703
+ count: 1,
3704
+ hasCount: false,
3705
+ };
3706
+
3707
+ commands.normal["*"](ctx);
3708
+
3709
+ assert.deepEqual(state.lastSearch, { pattern: "foo", direction: "/" });
3710
+ });
3711
+ });
3712
+
3713
+ describe("missing feature: zz center screen", () => {
3714
+ beforeEach(resetState);
3715
+
3716
+ it("zz does nothing when editorDoc is null", () => {
3717
+ state.editorDoc = null;
3718
+ const rep = makeRep(["hello"]);
3719
+ const { editorInfo } = makeMockEditorInfo();
3720
+ const ctx = {
3721
+ rep,
3722
+ editorInfo,
3723
+ line: 0,
3724
+ char: 0,
3725
+ lineText: "hello",
3726
+ count: 1,
3727
+ hasCount: false,
3728
+ };
3729
+
3730
+ assert.doesNotThrow(() => commands.normal["zz"](ctx));
3731
+ });
3732
+ });