@fresh-editor/fresh-editor 0.1.67 → 0.1.70

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 (64) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +2 -0
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.i18n.json +380 -0
  5. package/plugins/audit_mode.ts +836 -68
  6. package/plugins/buffer_modified.i18n.json +32 -0
  7. package/plugins/buffer_modified.ts +5 -3
  8. package/plugins/calculator.i18n.json +44 -0
  9. package/plugins/calculator.ts +6 -4
  10. package/plugins/clangd-lsp.ts +2 -0
  11. package/plugins/clangd_support.i18n.json +104 -0
  12. package/plugins/clangd_support.ts +18 -16
  13. package/plugins/color_highlighter.i18n.json +68 -0
  14. package/plugins/color_highlighter.ts +12 -10
  15. package/plugins/config-schema.json +28 -145
  16. package/plugins/csharp-lsp.ts +2 -0
  17. package/plugins/csharp_support.i18n.json +38 -0
  18. package/plugins/csharp_support.ts +6 -4
  19. package/plugins/css-lsp.ts +2 -0
  20. package/plugins/diagnostics_panel.i18n.json +110 -0
  21. package/plugins/diagnostics_panel.ts +19 -17
  22. package/plugins/find_references.i18n.json +128 -0
  23. package/plugins/find_references.ts +22 -20
  24. package/plugins/git_blame.i18n.json +230 -0
  25. package/plugins/git_blame.ts +39 -37
  26. package/plugins/git_find_file.i18n.json +146 -0
  27. package/plugins/git_find_file.ts +24 -22
  28. package/plugins/git_grep.i18n.json +80 -0
  29. package/plugins/git_grep.ts +15 -13
  30. package/plugins/git_gutter.i18n.json +44 -0
  31. package/plugins/git_gutter.ts +7 -5
  32. package/plugins/git_log.i18n.json +224 -0
  33. package/plugins/git_log.ts +41 -39
  34. package/plugins/go-lsp.ts +2 -0
  35. package/plugins/html-lsp.ts +2 -0
  36. package/plugins/json-lsp.ts +2 -0
  37. package/plugins/lib/fresh.d.ts +53 -13
  38. package/plugins/lib/index.ts +1 -1
  39. package/plugins/lib/navigation-controller.ts +3 -3
  40. package/plugins/lib/panel-manager.ts +15 -13
  41. package/plugins/lib/virtual-buffer-factory.ts +84 -112
  42. package/plugins/live_grep.i18n.json +80 -0
  43. package/plugins/live_grep.ts +15 -13
  44. package/plugins/markdown_compose.i18n.json +104 -0
  45. package/plugins/markdown_compose.ts +17 -15
  46. package/plugins/merge_conflict.i18n.json +380 -0
  47. package/plugins/merge_conflict.ts +72 -73
  48. package/plugins/path_complete.i18n.json +38 -0
  49. package/plugins/path_complete.ts +6 -4
  50. package/plugins/python-lsp.ts +2 -0
  51. package/plugins/rust-lsp.ts +2 -0
  52. package/plugins/search_replace.i18n.json +188 -0
  53. package/plugins/search_replace.ts +31 -29
  54. package/plugins/test_i18n.i18n.json +12 -0
  55. package/plugins/test_i18n.ts +18 -0
  56. package/plugins/theme_editor.i18n.json +1417 -0
  57. package/plugins/theme_editor.ts +73 -69
  58. package/plugins/todo_highlighter.i18n.json +86 -0
  59. package/plugins/todo_highlighter.ts +15 -13
  60. package/plugins/typescript-lsp.ts +2 -0
  61. package/plugins/vi_mode.i18n.json +716 -0
  62. package/plugins/vi_mode.ts +1195 -78
  63. package/plugins/welcome.i18n.json +110 -0
  64. package/plugins/welcome.ts +18 -16
@@ -1,4 +1,6 @@
1
1
  /// <reference path="./lib/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
2
4
 
3
5
  /**
4
6
  * Vi Mode Plugin for Fresh Editor
@@ -13,10 +15,23 @@
13
15
  */
14
16
 
15
17
  // Vi mode state
16
- type ViMode = "normal" | "insert" | "operator-pending" | "find-char" | "visual" | "visual-line" | "text-object";
18
+ type ViMode = "normal" | "insert" | "operator-pending" | "find-char" | "visual" | "visual-line" | "visual-block" | "text-object";
17
19
  type FindCharType = "f" | "t" | "F" | "T" | null;
18
20
  type TextObjectType = "inner" | "around" | null;
19
21
 
22
+ // Types for tracking repeatable changes
23
+ type ChangeType = "simple" | "operator-motion" | "operator-textobj" | "insert" | "line-op";
24
+
25
+ interface LastChange {
26
+ type: ChangeType;
27
+ action?: string; // For simple actions like "delete_forward", "delete_line"
28
+ operator?: string; // For operator+motion/textobj: "d", "c", "y"
29
+ motion?: string; // For operator+motion: the motion action
30
+ textObject?: { modifier: TextObjectType; object: string }; // For operator+textobj
31
+ count?: number; // Count used with the command
32
+ insertedText?: string; // Text inserted during insert mode
33
+ }
34
+
20
35
  interface ViState {
21
36
  mode: ViMode;
22
37
  pendingOperator: string | null;
@@ -24,9 +39,11 @@ interface ViState {
24
39
  pendingTextObject: TextObjectType; // For i/a text objects
25
40
  lastFindChar: { type: FindCharType; char: string } | null; // For ; and , repeat
26
41
  count: number | null;
27
- lastCommand: (() => void) | null; // For '.' repeat
42
+ lastChange: LastChange | null; // For '.' repeat
28
43
  lastYankWasLinewise: boolean; // Track if last yank was line-wise for proper paste
29
44
  visualAnchor: number | null; // Starting position for visual mode selection
45
+ insertStartPos: number | null; // Cursor position when entering insert mode
46
+ visualBlockAnchor: { line: number; col: number } | null; // For visual block mode
30
47
  }
31
48
 
32
49
  const state: ViState = {
@@ -36,9 +53,11 @@ const state: ViState = {
36
53
  pendingTextObject: null,
37
54
  lastFindChar: null,
38
55
  count: null,
39
- lastCommand: null,
56
+ lastChange: null,
40
57
  lastYankWasLinewise: false,
41
58
  visualAnchor: null,
59
+ insertStartPos: null,
60
+ visualBlockAnchor: null,
42
61
  };
43
62
 
44
63
  // Mode indicator for status bar
@@ -46,17 +65,19 @@ function getModeIndicator(mode: ViMode): string {
46
65
  const countPrefix = state.count !== null ? `${state.count} ` : "";
47
66
  switch (mode) {
48
67
  case "normal":
49
- return `-- NORMAL --${countPrefix ? ` (${state.count})` : ""}`;
68
+ return `-- ${editor.t("mode.normal")} --${countPrefix ? ` (${state.count})` : ""}`;
50
69
  case "insert":
51
- return "-- INSERT --";
70
+ return `-- ${editor.t("mode.insert")} --`;
52
71
  case "operator-pending":
53
- return `-- OPERATOR (${state.pendingOperator}) --${countPrefix ? ` (${state.count})` : ""}`;
72
+ return `-- ${editor.t("mode.operator")} (${state.pendingOperator}) --${countPrefix ? ` (${state.count})` : ""}`;
54
73
  case "find-char":
55
- return `-- FIND (${state.pendingFindChar}) --`;
74
+ return `-- ${editor.t("mode.find")} (${state.pendingFindChar}) --`;
56
75
  case "visual":
57
- return `-- VISUAL --${countPrefix ? ` (${state.count})` : ""}`;
76
+ return `-- ${editor.t("mode.visual")} --${countPrefix ? ` (${state.count})` : ""}`;
58
77
  case "visual-line":
59
- return `-- VISUAL LINE --${countPrefix ? ` (${state.count})` : ""}`;
78
+ return `-- ${editor.t("mode.visual_line")} --${countPrefix ? ` (${state.count})` : ""}`;
79
+ case "visual-block":
80
+ return `-- ${editor.t("mode.visual_block")} --${countPrefix ? ` (${state.count})` : ""}`;
60
81
  case "text-object":
61
82
  return `-- ${state.pendingOperator}${state.pendingTextObject === "inner" ? "i" : "a"}? --`;
62
83
  default:
@@ -81,27 +102,69 @@ function switchMode(newMode: ViMode): void {
81
102
 
82
103
  // Preserve count when entering operator-pending or text-object mode (for 3dw = delete 3 words)
83
104
  // Also preserve count in visual modes
84
- if (newMode !== "operator-pending" && newMode !== "text-object" && newMode !== "visual" && newMode !== "visual-line") {
105
+ if (newMode !== "operator-pending" && newMode !== "text-object" &&
106
+ newMode !== "visual" && newMode !== "visual-line" && newMode !== "visual-block") {
85
107
  state.count = null;
86
108
  }
87
109
 
88
110
  // Clear visual anchor when leaving visual modes
89
- if (newMode !== "visual" && newMode !== "visual-line") {
111
+ if (newMode !== "visual" && newMode !== "visual-line" && newMode !== "visual-block") {
90
112
  state.visualAnchor = null;
113
+ state.visualBlockAnchor = null;
91
114
  // Clear any selection when leaving visual mode by moving cursor
92
115
  // (any non-select movement clears selection in Fresh)
93
- if (oldMode === "visual" || oldMode === "visual-line") {
116
+ if (oldMode === "visual" || oldMode === "visual-line" || oldMode === "visual-block") {
94
117
  editor.executeAction("move_left");
95
118
  editor.executeAction("move_right");
96
119
  }
97
120
  }
98
121
 
122
+ // Track insert mode start position for '.' repeat
123
+ if (newMode === "insert" && oldMode !== "insert") {
124
+ state.insertStartPos = editor.getCursorPosition();
125
+ }
126
+
127
+ // Capture inserted text when leaving insert mode (for '.' repeat)
128
+ if (oldMode === "insert" && newMode !== "insert" && state.insertStartPos !== null) {
129
+ captureInsertedText();
130
+ }
131
+
99
132
  // All modes use vi-{mode} naming, including insert mode
100
133
  // vi-insert has read_only=false so normal typing works, but Escape is bound
101
134
  editor.setEditorMode(`vi-${newMode}`);
102
135
  editor.setStatus(getModeIndicator(newMode));
103
136
  }
104
137
 
138
+ // Capture text inserted during insert mode for '.' repeat
139
+ async function captureInsertedText(): Promise<void> {
140
+ if (state.insertStartPos === null) return;
141
+
142
+ const endPos = editor.getCursorPosition();
143
+ if (endPos === null || endPos <= state.insertStartPos) {
144
+ state.insertStartPos = null;
145
+ return;
146
+ }
147
+
148
+ const bufferId = editor.getActiveBufferId();
149
+ const text = await editor.getBufferText(bufferId, state.insertStartPos, endPos);
150
+
151
+ if (text && text.length > 0) {
152
+ // Only record if we have a pending insert change or if there was actual text inserted
153
+ if (state.lastChange?.type === "insert" || !state.lastChange) {
154
+ state.lastChange = {
155
+ type: "insert",
156
+ insertedText: text,
157
+ };
158
+ } else if (state.lastChange.type === "simple" || state.lastChange.type === "operator-motion" ||
159
+ state.lastChange.type === "operator-textobj" || state.lastChange.type === "line-op") {
160
+ // A change command (c, s, etc.) was used - append the inserted text
161
+ state.lastChange.insertedText = text;
162
+ }
163
+ }
164
+
165
+ state.insertStartPos = null;
166
+ }
167
+
105
168
  // Get the current count (defaults to 1 if no count specified)
106
169
  // Does NOT clear the count - that's done in switchMode or explicitly
107
170
  function getCount(): number {
@@ -175,6 +238,11 @@ const atomicOperatorActions: OperatorMotionMap = {
175
238
  // Apply an operator using atomic actions if available, otherwise selection-based approach
176
239
  // The count parameter specifies how many times to apply the motion (e.g., d3w = delete 3 words)
177
240
  function applyOperatorWithMotion(operator: string, motionAction: string, count: number = 1): void {
241
+ // Record last change for '.' repeat (only for delete and change, not yank)
242
+ if (operator === "d" || operator === "c") {
243
+ state.lastChange = { type: "operator-motion", operator, motion: motionAction, count };
244
+ }
245
+
178
246
  // For "change" operator, use delete action and then enter insert mode
179
247
  const lookupOperator = operator === "c" ? "d" : operator;
180
248
 
@@ -379,6 +447,7 @@ globalThis.vi_yank_operator = function (): void {
379
447
  // Line operations (dd, cc, yy) - support count prefix (3dd = delete 3 lines)
380
448
  globalThis.vi_delete_line = function (): void {
381
449
  const count = consumeCount();
450
+ state.lastChange = { type: "line-op", action: "delete_line", count };
382
451
  if (count === 1) {
383
452
  editor.executeAction("delete_line");
384
453
  } else {
@@ -388,7 +457,8 @@ globalThis.vi_delete_line = function (): void {
388
457
  };
389
458
 
390
459
  globalThis.vi_change_line = function (): void {
391
- consumeCount(); // TODO: support count for change line
460
+ const count = consumeCount();
461
+ state.lastChange = { type: "line-op", action: "change_line", count };
392
462
  editor.executeAction("move_line_start");
393
463
  const start = editor.getCursorPosition();
394
464
  editor.executeAction("move_line_end");
@@ -413,32 +483,43 @@ globalThis.vi_yank_line = function (): void {
413
483
  editor.executeAction("move_up");
414
484
  editor.executeAction("move_line_start");
415
485
  state.lastYankWasLinewise = true;
416
- editor.setStatus(`Yanked ${count} line${count > 1 ? "s" : ""}`);
486
+ editor.setStatus(editor.t("status.yanked_lines", { count: String(count) }));
417
487
  switchMode("normal");
418
488
  };
419
489
 
420
490
  // Single character operations - support count prefix (3x = delete 3 chars)
421
491
  globalThis.vi_delete_char = function (): void {
422
- executeWithCount("delete_forward");
492
+ const count = consumeCount();
493
+ state.lastChange = { type: "simple", action: "delete_forward", count };
494
+ executeWithCount("delete_forward", count);
423
495
  };
424
496
 
425
497
  globalThis.vi_delete_char_before = function (): void {
426
- executeWithCount("delete_backward");
498
+ const count = consumeCount();
499
+ state.lastChange = { type: "simple", action: "delete_backward", count };
500
+ executeWithCount("delete_backward", count);
427
501
  };
428
502
 
429
503
  globalThis.vi_replace_char = function (): void {
430
504
  // TODO: implement character replacement (need to read next char)
431
- editor.setStatus("Replace char not yet implemented");
505
+ editor.setStatus(editor.t("status.replace_not_implemented"));
432
506
  };
433
507
 
434
508
  // Substitute (delete char and enter insert mode)
435
509
  globalThis.vi_substitute = function (): void {
436
- editor.executeAction("delete_forward");
510
+ const count = consumeCount();
511
+ state.lastChange = { type: "simple", action: "substitute", count };
512
+ if (count > 1) {
513
+ editor.executeActions([{ action: "delete_forward", count }]);
514
+ } else {
515
+ editor.executeAction("delete_forward");
516
+ }
437
517
  switchMode("insert");
438
518
  };
439
519
 
440
520
  // Delete to end of line
441
521
  globalThis.vi_delete_to_end = function (): void {
522
+ state.lastChange = { type: "operator-motion", operator: "d", motion: "move_line_end" };
442
523
  const start = editor.getCursorPosition();
443
524
  editor.executeAction("move_line_end");
444
525
  const end = editor.getCursorPosition();
@@ -449,6 +530,7 @@ globalThis.vi_delete_to_end = function (): void {
449
530
 
450
531
  // Change to end of line
451
532
  globalThis.vi_change_to_end = function (): void {
533
+ state.lastChange = { type: "operator-motion", operator: "c", motion: "move_line_end" };
452
534
  const start = editor.getCursorPosition();
453
535
  editor.executeAction("move_line_end");
454
536
  const end = editor.getCursorPosition();
@@ -498,6 +580,104 @@ globalThis.vi_redo = function (): void {
498
580
  editor.executeAction("redo");
499
581
  };
500
582
 
583
+ // Repeat last change (. command)
584
+ globalThis.vi_repeat = async function (): Promise<void> {
585
+ if (!state.lastChange) {
586
+ editor.setStatus(editor.t("status.no_change_to_repeat"));
587
+ return;
588
+ }
589
+
590
+ const change = state.lastChange;
591
+ const count = consumeCount() || change.count || 1;
592
+
593
+ switch (change.type) {
594
+ case "simple": {
595
+ // Simple actions like x, X, s
596
+ if (change.action === "substitute") {
597
+ // Substitute: delete chars and insert text
598
+ if (count > 1) {
599
+ editor.executeActions([{ action: "delete_forward", count }]);
600
+ } else {
601
+ editor.executeAction("delete_forward");
602
+ }
603
+ if (change.insertedText) {
604
+ editor.insertText(change.insertedText);
605
+ }
606
+ } else if (change.action) {
607
+ // Simple action like delete_forward, delete_backward
608
+ if (count > 1) {
609
+ editor.executeActions([{ action: change.action, count }]);
610
+ } else {
611
+ editor.executeAction(change.action);
612
+ }
613
+ }
614
+ break;
615
+ }
616
+
617
+ case "line-op": {
618
+ // Line operations like dd, cc
619
+ if (change.action === "delete_line") {
620
+ if (count > 1) {
621
+ editor.executeActions([{ action: "delete_line", count }]);
622
+ } else {
623
+ editor.executeAction("delete_line");
624
+ }
625
+ } else if (change.action === "change_line") {
626
+ // Change line: delete line content and insert text
627
+ editor.executeAction("move_line_start");
628
+ const start = editor.getCursorPosition();
629
+ editor.executeAction("move_line_end");
630
+ const end = editor.getCursorPosition();
631
+ if (start !== null && end !== null) {
632
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
633
+ }
634
+ if (change.insertedText) {
635
+ editor.insertText(change.insertedText);
636
+ }
637
+ }
638
+ break;
639
+ }
640
+
641
+ case "operator-motion": {
642
+ // Operator + motion like dw, cw, d$
643
+ if (change.operator && change.motion) {
644
+ if (change.operator === "c") {
645
+ // For change: do the delete part, then insert the text
646
+ applyOperatorWithMotion("d", change.motion, count);
647
+ if (change.insertedText) {
648
+ editor.insertText(change.insertedText);
649
+ }
650
+ } else {
651
+ applyOperatorWithMotion(change.operator, change.motion, count);
652
+ }
653
+ }
654
+ break;
655
+ }
656
+
657
+ case "operator-textobj": {
658
+ // Operator + text object like diw, ci"
659
+ if (change.operator && change.textObject) {
660
+ // Set up the pending state and call applyTextObject
661
+ state.pendingOperator = change.operator === "c" ? "d" : change.operator;
662
+ state.pendingTextObject = change.textObject.modifier;
663
+ await applyTextObject(change.textObject.object);
664
+ if (change.operator === "c" && change.insertedText) {
665
+ editor.insertText(change.insertedText);
666
+ }
667
+ }
668
+ break;
669
+ }
670
+
671
+ case "insert": {
672
+ // Pure insert (i, a, o, O)
673
+ if (change.insertedText) {
674
+ editor.insertText(change.insertedText);
675
+ }
676
+ break;
677
+ }
678
+ }
679
+ };
680
+
501
681
  // Join lines
502
682
  globalThis.vi_join = function (): void {
503
683
  editor.executeAction("move_line_end");
@@ -610,6 +790,95 @@ globalThis.vi_visual_toggle_line = function (): void {
610
790
  }
611
791
  };
612
792
 
793
+ // Enter visual block mode (Ctrl-v)
794
+ globalThis.vi_visual_block = function (): void {
795
+ // Store anchor position for block selection
796
+ state.visualAnchor = editor.getCursorPosition();
797
+
798
+ // Calculate line and column for block anchor
799
+ const cursorPos = editor.getCursorPosition();
800
+ if (cursorPos !== null) {
801
+ const line = editor.getCursorLine() ?? 1;
802
+ const lineStart = editor.getLineStartPosition(line);
803
+ const col = lineStart !== null ? cursorPos - lineStart : 0;
804
+ state.visualBlockAnchor = { line, col };
805
+ }
806
+
807
+ // Select current character to start
808
+ editor.executeAction("select_right");
809
+ switchMode("visual-block");
810
+ };
811
+
812
+ // Visual block mode motions - these extend the rectangular selection
813
+ globalThis.vi_vblock_left = function (): void {
814
+ executeWithCount("select_left");
815
+ };
816
+
817
+ globalThis.vi_vblock_down = function (): void {
818
+ executeWithCount("select_down");
819
+ };
820
+
821
+ globalThis.vi_vblock_up = function (): void {
822
+ executeWithCount("select_up");
823
+ };
824
+
825
+ globalThis.vi_vblock_right = function (): void {
826
+ executeWithCount("select_right");
827
+ };
828
+
829
+ globalThis.vi_vblock_line_start = function (): void {
830
+ consumeCount();
831
+ editor.executeAction("select_line_start");
832
+ };
833
+
834
+ globalThis.vi_vblock_line_end = function (): void {
835
+ consumeCount();
836
+ editor.executeAction("select_line_end");
837
+ };
838
+
839
+ // Visual block delete - delete the selected block
840
+ globalThis.vi_vblock_delete = function (): void {
841
+ editor.executeAction("cut");
842
+ state.lastYankWasLinewise = false;
843
+ switchMode("normal");
844
+ };
845
+
846
+ // Visual block change - delete and enter insert mode
847
+ globalThis.vi_vblock_change = function (): void {
848
+ editor.executeAction("cut");
849
+ switchMode("insert");
850
+ };
851
+
852
+ // Visual block yank
853
+ globalThis.vi_vblock_yank = function (): void {
854
+ editor.executeAction("copy");
855
+ state.lastYankWasLinewise = false;
856
+ // Move cursor to start of selection
857
+ editor.executeAction("move_left");
858
+ switchMode("normal");
859
+ };
860
+
861
+ // Exit visual block mode
862
+ globalThis.vi_vblock_escape = function (): void {
863
+ switchMode("normal");
864
+ };
865
+
866
+ // Toggle from visual block to other visual modes
867
+ globalThis.vi_vblock_toggle_char = function (): void {
868
+ // Switch to character visual mode
869
+ state.mode = "visual";
870
+ editor.setEditorMode("vi-visual");
871
+ editor.setStatus(getModeIndicator("visual"));
872
+ };
873
+
874
+ globalThis.vi_vblock_toggle_line = function (): void {
875
+ // Switch to line visual mode
876
+ editor.executeAction("select_line");
877
+ state.mode = "visual-line";
878
+ editor.setEditorMode("vi-visual-line");
879
+ editor.setStatus(getModeIndicator("visual-line"));
880
+ };
881
+
613
882
  // Visual mode motions - these extend the selection
614
883
  globalThis.vi_vis_left = function (): void {
615
884
  executeWithCount("select_left");
@@ -727,12 +996,18 @@ globalThis.vi_text_object_around = function (): void {
727
996
  async function applyTextObject(objectType: string): Promise<void> {
728
997
  const operator = state.pendingOperator;
729
998
  const isInner = state.pendingTextObject === "inner";
999
+ const modifier = state.pendingTextObject;
730
1000
 
731
1001
  if (!operator) {
732
1002
  switchMode("normal");
733
1003
  return;
734
1004
  }
735
1005
 
1006
+ // Record last change for '.' repeat (only for delete and change, not yank)
1007
+ if ((operator === "d" || operator === "c") && modifier) {
1008
+ state.lastChange = { type: "operator-textobj", operator, textObject: { modifier, object: objectType } };
1009
+ }
1010
+
736
1011
  const bufferId = editor.getActiveBufferId();
737
1012
  const cursorPos = editor.getCursorPosition();
738
1013
  if (cursorPos === null) {
@@ -1269,12 +1544,19 @@ editor.defineMode("vi-normal", null, [
1269
1544
  ["u", "vi_undo"],
1270
1545
  ["C-r", "vi_redo"],
1271
1546
 
1547
+ // Repeat last change
1548
+ [".", "vi_repeat"],
1549
+
1272
1550
  // Visual mode
1273
1551
  ["v", "vi_visual_char"],
1274
1552
  ["V", "vi_visual_line"],
1553
+ ["C-v", "vi_visual_block"],
1275
1554
 
1276
1555
  // Other
1277
1556
  ["J", "vi_join"],
1557
+
1558
+ // Command mode
1559
+ [":", "vi_command_mode"],
1278
1560
  ], true); // read_only = true to prevent character insertion
1279
1561
 
1280
1562
  // Define vi-insert mode - only Escape is special, other keys insert text
@@ -1519,83 +1801,918 @@ editor.defineMode("vi-visual-line", null, [
1519
1801
  ["V", "vi_vis_escape"], // V again exits visual-line mode
1520
1802
  ], true);
1521
1803
 
1804
+ // Define vi-visual-block mode (column/block selection)
1805
+ editor.defineMode("vi-visual-block", null, [
1806
+ // Count prefix
1807
+ ["1", "vi_digit_1"],
1808
+ ["2", "vi_digit_2"],
1809
+ ["3", "vi_digit_3"],
1810
+ ["4", "vi_digit_4"],
1811
+ ["5", "vi_digit_5"],
1812
+ ["6", "vi_digit_6"],
1813
+ ["7", "vi_digit_7"],
1814
+ ["8", "vi_digit_8"],
1815
+ ["9", "vi_digit_9"],
1816
+ ["0", "vi_vblock_line_start"],
1817
+
1818
+ // Motions (extend block selection)
1819
+ ["h", "vi_vblock_left"],
1820
+ ["j", "vi_vblock_down"],
1821
+ ["k", "vi_vblock_up"],
1822
+ ["l", "vi_vblock_right"],
1823
+ ["$", "vi_vblock_line_end"],
1824
+ ["^", "vi_vblock_line_start"],
1825
+
1826
+ // Switch to other visual modes
1827
+ ["v", "vi_vblock_toggle_char"],
1828
+ ["V", "vi_vblock_toggle_line"],
1829
+
1830
+ // Operators
1831
+ ["d", "vi_vblock_delete"],
1832
+ ["x", "vi_vblock_delete"],
1833
+ ["c", "vi_vblock_change"],
1834
+ ["s", "vi_vblock_change"],
1835
+ ["y", "vi_vblock_yank"],
1836
+
1837
+ // Exit
1838
+ ["Escape", "vi_vblock_escape"],
1839
+ ["C-v", "vi_vblock_escape"], // Ctrl-v again exits visual-block mode
1840
+ ], true);
1841
+
1522
1842
  // ============================================================================
1523
1843
  // Register Commands
1524
1844
  // ============================================================================
1525
1845
 
1526
1846
  // Navigation commands
1527
1847
  const navCommands = [
1528
- ["vi_left", "Move left"],
1529
- ["vi_down", "Move down"],
1530
- ["vi_up", "Move up"],
1531
- ["vi_right", "Move right"],
1532
- ["vi_word", "Move to next word"],
1533
- ["vi_word_back", "Move to previous word"],
1534
- ["vi_word_end", "Move to end of word"],
1535
- ["vi_line_start", "Move to line start"],
1536
- ["vi_line_end", "Move to line end"],
1537
- ["vi_doc_start", "Move to document start"],
1538
- ["vi_doc_end", "Move to document end"],
1539
- ["vi_page_down", "Page down"],
1540
- ["vi_page_up", "Page up"],
1541
- ["vi_half_page_down", "Half page down"],
1542
- ["vi_half_page_up", "Half page up"],
1543
- ["vi_center_cursor", "Center cursor on screen"],
1544
- ["vi_search_forward", "Search forward"],
1545
- ["vi_search_backward", "Search backward"],
1546
- ["vi_find_next", "Find next match"],
1547
- ["vi_find_prev", "Find previous match"],
1548
- ["vi_find_char_f", "Find char forward"],
1549
- ["vi_find_char_t", "Find till char forward"],
1550
- ["vi_find_char_F", "Find char backward"],
1551
- ["vi_find_char_T", "Find till char backward"],
1552
- ["vi_find_char_repeat", "Repeat last find char"],
1553
- ["vi_find_char_repeat_reverse", "Repeat last find char (reverse)"],
1848
+ ["vi_left", "move_left"],
1849
+ ["vi_down", "move_down"],
1850
+ ["vi_up", "move_up"],
1851
+ ["vi_right", "move_right"],
1852
+ ["vi_word", "move_word"],
1853
+ ["vi_word_back", "move_word_back"],
1854
+ ["vi_word_end", "move_word_end"],
1855
+ ["vi_line_start", "move_line_start"],
1856
+ ["vi_line_end", "move_line_end"],
1857
+ ["vi_doc_start", "move_doc_start"],
1858
+ ["vi_doc_end", "move_doc_end"],
1859
+ ["vi_page_down", "page_down"],
1860
+ ["vi_page_up", "page_up"],
1861
+ ["vi_half_page_down", "half_page_down"],
1862
+ ["vi_half_page_up", "half_page_up"],
1863
+ ["vi_center_cursor", "center_cursor"],
1864
+ ["vi_search_forward", "search_forward"],
1865
+ ["vi_search_backward", "search_backward"],
1866
+ ["vi_find_next", "find_next"],
1867
+ ["vi_find_prev", "find_prev"],
1868
+ ["vi_find_char_f", "find_char_f"],
1869
+ ["vi_find_char_t", "find_char_t"],
1870
+ ["vi_find_char_F", "find_char_F"],
1871
+ ["vi_find_char_T", "find_char_T"],
1872
+ ["vi_find_char_repeat", "find_char_repeat"],
1873
+ ["vi_find_char_repeat_reverse", "find_char_repeat_reverse"],
1554
1874
  ];
1555
1875
 
1556
- for (const [name, desc] of navCommands) {
1557
- editor.registerCommand(name, `Vi: ${desc}`, name, "vi-normal");
1876
+ for (const [name, key] of navCommands) {
1877
+ editor.registerCommand(`%cmd.${key}`, `%cmd.${key}`, name, "vi-normal");
1558
1878
  }
1559
1879
 
1560
1880
  // Mode commands
1561
1881
  const modeCommands = [
1562
- ["vi_insert_before", "Insert before cursor"],
1563
- ["vi_insert_after", "Insert after cursor"],
1564
- ["vi_insert_line_start", "Insert at line start"],
1565
- ["vi_insert_line_end", "Insert at line end"],
1566
- ["vi_open_below", "Open line below"],
1567
- ["vi_open_above", "Open line above"],
1568
- ["vi_escape", "Return to normal mode"],
1882
+ ["vi_insert_before", "insert_before"],
1883
+ ["vi_insert_after", "insert_after"],
1884
+ ["vi_insert_line_start", "insert_line_start"],
1885
+ ["vi_insert_line_end", "insert_line_end"],
1886
+ ["vi_open_below", "open_below"],
1887
+ ["vi_open_above", "open_above"],
1888
+ ["vi_escape", "return_to_normal"],
1569
1889
  ];
1570
1890
 
1571
- for (const [name, desc] of modeCommands) {
1572
- editor.registerCommand(name, `Vi: ${desc}`, name, "vi-normal");
1891
+ for (const [name, key] of modeCommands) {
1892
+ editor.registerCommand(`%cmd.${key}`, `%cmd.${key}`, name, "vi-normal");
1573
1893
  }
1574
1894
 
1575
1895
  // Operator commands
1576
1896
  const opCommands = [
1577
- ["vi_delete_operator", "Delete operator"],
1578
- ["vi_change_operator", "Change operator"],
1579
- ["vi_yank_operator", "Yank operator"],
1580
- ["vi_delete_line", "Delete line"],
1581
- ["vi_change_line", "Change line"],
1582
- ["vi_yank_line", "Yank line"],
1583
- ["vi_delete_char", "Delete character"],
1584
- ["vi_delete_char_before", "Delete char before cursor"],
1585
- ["vi_substitute", "Substitute character"],
1586
- ["vi_delete_to_end", "Delete to end of line"],
1587
- ["vi_change_to_end", "Change to end of line"],
1588
- ["vi_paste_after", "Paste after"],
1589
- ["vi_paste_before", "Paste before"],
1590
- ["vi_undo", "Undo"],
1591
- ["vi_redo", "Redo"],
1592
- ["vi_join", "Join lines"],
1897
+ ["vi_delete_operator", "delete_operator"],
1898
+ ["vi_change_operator", "change_operator"],
1899
+ ["vi_yank_operator", "yank_operator"],
1900
+ ["vi_delete_line", "delete_line"],
1901
+ ["vi_change_line", "change_line"],
1902
+ ["vi_yank_line", "yank_line"],
1903
+ ["vi_delete_char", "delete_char"],
1904
+ ["vi_delete_char_before", "delete_char_before"],
1905
+ ["vi_substitute", "substitute"],
1906
+ ["vi_delete_to_end", "delete_to_end"],
1907
+ ["vi_change_to_end", "change_to_end"],
1908
+ ["vi_paste_after", "paste_after"],
1909
+ ["vi_paste_before", "paste_before"],
1910
+ ["vi_undo", "undo"],
1911
+ ["vi_redo", "redo"],
1912
+ ["vi_join", "join_lines"],
1593
1913
  ];
1594
1914
 
1595
- for (const [name, desc] of opCommands) {
1596
- editor.registerCommand(name, `Vi: ${desc}`, name, "vi-normal");
1915
+ for (const [name, key] of opCommands) {
1916
+ editor.registerCommand(`%cmd.${key}`, `%cmd.${key}`, name, "vi-normal");
1597
1917
  }
1598
1918
 
1919
+ // ============================================================================
1920
+ // Colon Command Mode (:w, :q, :wq, :q!, :e, etc.)
1921
+ // ============================================================================
1922
+
1923
+ // Start command mode - shows ":" prompt at the bottom
1924
+ globalThis.vi_command_mode = function (): void {
1925
+ editor.startPrompt(":", "vi-command");
1926
+ };
1927
+
1928
+ // Handle command execution when user presses Enter
1929
+ globalThis.vi_command_handler = async function (args: { prompt_type: string; input: string }): Promise<boolean> {
1930
+ if (args.prompt_type !== "vi-command") {
1931
+ return false; // Not our prompt, let other handlers process it
1932
+ }
1933
+
1934
+ const input = args.input.trim();
1935
+ if (!input) {
1936
+ return true; // Empty command, just dismiss
1937
+ }
1938
+
1939
+ // Parse the command
1940
+ const result = await executeViCommand(input);
1941
+
1942
+ if (result.error) {
1943
+ editor.setStatus(`E: ${result.error}`);
1944
+ } else if (result.message) {
1945
+ editor.setStatus(result.message);
1946
+ }
1947
+
1948
+ return true; // We handled it
1949
+ };
1950
+
1951
+ interface CommandResult {
1952
+ error?: string;
1953
+ message?: string;
1954
+ }
1955
+
1956
+ // Command definition for the command table
1957
+ interface CommandDef {
1958
+ name: string; // Full command name
1959
+ minAbbrev: number; // Minimum abbreviation length (e.g., 1 for "w" -> "write")
1960
+ allowBang: boolean; // Whether command accepts ! suffix
1961
+ hasArgs: boolean; // Whether command accepts arguments
1962
+ }
1963
+
1964
+ // Command table - defines all supported commands with their abbreviations
1965
+ // Vim allows any unambiguous prefix of a command name
1966
+ const commandTable: CommandDef[] = [
1967
+ // File operations
1968
+ { name: "write", minAbbrev: 1, allowBang: true, hasArgs: true }, // :w, :wri, :write
1969
+ { name: "quit", minAbbrev: 1, allowBang: true, hasArgs: false }, // :q, :qu, :quit
1970
+ { name: "wq", minAbbrev: 2, allowBang: true, hasArgs: false }, // :wq
1971
+ { name: "wall", minAbbrev: 2, allowBang: false, hasArgs: false }, // :wa, :wall
1972
+ { name: "qall", minAbbrev: 2, allowBang: true, hasArgs: false }, // :qa, :qall
1973
+ { name: "wqall", minAbbrev: 3, allowBang: false, hasArgs: false }, // :wqa, :wqall
1974
+ { name: "xit", minAbbrev: 1, allowBang: false, hasArgs: false }, // :x, :xit (same as :wq)
1975
+ { name: "exit", minAbbrev: 3, allowBang: false, hasArgs: false }, // :exi, :exit
1976
+ { name: "edit", minAbbrev: 1, allowBang: true, hasArgs: true }, // :e, :ed, :edit
1977
+ { name: "enew", minAbbrev: 3, allowBang: true, hasArgs: false }, // :ene, :enew
1978
+ { name: "saveas", minAbbrev: 3, allowBang: false, hasArgs: true }, // :sav, :saveas
1979
+
1980
+ // Buffer navigation
1981
+ { name: "next", minAbbrev: 1, allowBang: true, hasArgs: false }, // :n, :next
1982
+ { name: "previous", minAbbrev: 4, allowBang: true, hasArgs: false }, // :prev, :previous
1983
+ { name: "bnext", minAbbrev: 2, allowBang: false, hasArgs: false }, // :bn, :bnext
1984
+ { name: "bprevious", minAbbrev: 2, allowBang: false, hasArgs: false },// :bp, :bprev, :bprevious
1985
+ { name: "bdelete", minAbbrev: 2, allowBang: true, hasArgs: false }, // :bd, :bdelete
1986
+ { name: "buffer", minAbbrev: 1, allowBang: false, hasArgs: true }, // :b, :buffer
1987
+ { name: "buffers", minAbbrev: 2, allowBang: false, hasArgs: false }, // :bu, :buffers (same as :ls)
1988
+ { name: "ls", minAbbrev: 2, allowBang: false, hasArgs: false }, // :ls
1989
+ { name: "files", minAbbrev: 3, allowBang: false, hasArgs: false }, // :fil, :files
1990
+
1991
+ // Splits
1992
+ { name: "split", minAbbrev: 2, allowBang: false, hasArgs: true }, // :sp, :split
1993
+ { name: "vsplit", minAbbrev: 2, allowBang: false, hasArgs: true }, // :vs, :vsplit
1994
+ { name: "new", minAbbrev: 3, allowBang: false, hasArgs: true }, // :new
1995
+ { name: "vnew", minAbbrev: 3, allowBang: false, hasArgs: true }, // :vne, :vnew
1996
+ { name: "only", minAbbrev: 2, allowBang: true, hasArgs: false }, // :on, :only
1997
+ { name: "close", minAbbrev: 3, allowBang: true, hasArgs: false }, // :clo, :close
1998
+
1999
+ // Tabs (mapped to buffers in Fresh)
2000
+ { name: "tabnew", minAbbrev: 4, allowBang: false, hasArgs: true }, // :tabn, :tabnew
2001
+ { name: "tabedit", minAbbrev: 4, allowBang: false, hasArgs: true }, // :tabe, :tabedit
2002
+ { name: "tabclose", minAbbrev: 4, allowBang: true, hasArgs: false }, // :tabc, :tabclose
2003
+ { name: "tabnext", minAbbrev: 5, allowBang: false, hasArgs: false }, // :tabne, :tabnext (note: different from :tabn)
2004
+ { name: "tabprevious", minAbbrev: 4, allowBang: false, hasArgs: false }, // :tabp, :tabprevious
2005
+
2006
+ // Quickfix (mapped to diagnostics in Fresh)
2007
+ { name: "copen", minAbbrev: 3, allowBang: false, hasArgs: false }, // :cop, :copen
2008
+ { name: "cclose", minAbbrev: 3, allowBang: false, hasArgs: false }, // :ccl, :cclose
2009
+ { name: "cnext", minAbbrev: 2, allowBang: true, hasArgs: false }, // :cn, :cnext
2010
+ { name: "cprevious", minAbbrev: 2, allowBang: true, hasArgs: false },// :cp, :cprev, :cprevious
2011
+ { name: "cfirst", minAbbrev: 3, allowBang: true, hasArgs: false }, // :cfir, :cfirst
2012
+ { name: "clast", minAbbrev: 3, allowBang: true, hasArgs: false }, // :cla, :clast
2013
+
2014
+ // Search and replace
2015
+ { name: "nohlsearch", minAbbrev: 3, allowBang: false, hasArgs: false }, // :noh, :nohlsearch
2016
+ { name: "substitute", minAbbrev: 1, allowBang: false, hasArgs: true }, // :s, :substitute
2017
+ { name: "global", minAbbrev: 1, allowBang: false, hasArgs: true }, // :g, :global
2018
+ { name: "vglobal", minAbbrev: 2, allowBang: false, hasArgs: true }, // :vg, :vglobal
2019
+
2020
+ // Undo/redo
2021
+ { name: "undo", minAbbrev: 1, allowBang: true, hasArgs: false }, // :u, :undo
2022
+ { name: "redo", minAbbrev: 3, allowBang: false, hasArgs: false }, // :red, :redo
2023
+
2024
+ // Settings
2025
+ { name: "set", minAbbrev: 2, allowBang: false, hasArgs: true }, // :se, :set
2026
+
2027
+ // Info commands
2028
+ { name: "pwd", minAbbrev: 2, allowBang: false, hasArgs: false }, // :pw, :pwd
2029
+ { name: "cd", minAbbrev: 2, allowBang: false, hasArgs: true }, // :cd
2030
+ { name: "file", minAbbrev: 1, allowBang: false, hasArgs: true }, // :f, :file
2031
+ { name: "help", minAbbrev: 1, allowBang: false, hasArgs: true }, // :h, :help
2032
+ { name: "version", minAbbrev: 3, allowBang: false, hasArgs: false }, // :ver, :version
2033
+
2034
+ // Other
2035
+ { name: "marks", minAbbrev: 4, allowBang: false, hasArgs: false }, // :mark, :marks
2036
+ { name: "registers", minAbbrev: 3, allowBang: false, hasArgs: false },// :reg, :registers
2037
+ { name: "jumps", minAbbrev: 2, allowBang: false, hasArgs: false }, // :ju, :jumps
2038
+ { name: "syntax", minAbbrev: 2, allowBang: false, hasArgs: true }, // :sy, :syntax
2039
+ { name: "read", minAbbrev: 1, allowBang: false, hasArgs: true }, // :r, :read
2040
+ { name: "grep", minAbbrev: 2, allowBang: false, hasArgs: true }, // :gr, :grep
2041
+ { name: "vimgrep", minAbbrev: 3, allowBang: false, hasArgs: true }, // :vim, :vimgrep
2042
+ { name: "make", minAbbrev: 3, allowBang: true, hasArgs: true }, // :mak, :make
2043
+ { name: "ascii", minAbbrev: 2, allowBang: false, hasArgs: false }, // :as, :ascii
2044
+ { name: "revert", minAbbrev: 3, allowBang: false, hasArgs: false }, // :rev, :revert (Fresh-specific)
2045
+ ];
2046
+
2047
+ // Find a command by name or abbreviation
2048
+ function findCommand(input: string): CommandDef | null {
2049
+ // Exact match first
2050
+ for (const cmd of commandTable) {
2051
+ if (cmd.name === input) {
2052
+ return cmd;
2053
+ }
2054
+ }
2055
+
2056
+ // Then try abbreviation matching
2057
+ const matches: CommandDef[] = [];
2058
+ for (const cmd of commandTable) {
2059
+ // Input must be at least minAbbrev chars and be a prefix of the command name
2060
+ if (input.length >= cmd.minAbbrev && cmd.name.startsWith(input)) {
2061
+ matches.push(cmd);
2062
+ }
2063
+ }
2064
+
2065
+ // Return only if unambiguous
2066
+ if (matches.length === 1) {
2067
+ return matches[0];
2068
+ }
2069
+
2070
+ // Handle special short aliases that vim supports even if ambiguous
2071
+ // These are the classic vim abbreviations that always work
2072
+ const shortAliases: Record<string, string> = {
2073
+ "w": "write",
2074
+ "q": "quit",
2075
+ "e": "edit",
2076
+ "n": "next",
2077
+ "N": "previous",
2078
+ "b": "buffer",
2079
+ "f": "file",
2080
+ "h": "help",
2081
+ "u": "undo",
2082
+ "r": "read",
2083
+ "s": "substitute",
2084
+ "g": "global",
2085
+ "x": "xit",
2086
+ };
2087
+
2088
+ if (shortAliases[input]) {
2089
+ return commandTable.find(c => c.name === shortAliases[input]) || null;
2090
+ }
2091
+
2092
+ return null;
2093
+ }
2094
+
2095
+ // Execute a vi command and return result
2096
+ async function executeViCommand(cmd: string): Promise<CommandResult> {
2097
+ // Handle pure line numbers first (e.g., :42)
2098
+ const lineNumMatch = cmd.match(/^(\d+)$/);
2099
+ if (lineNumMatch) {
2100
+ const lineNum = parseInt(lineNumMatch[1], 10);
2101
+ return gotoLine(lineNum);
2102
+ }
2103
+
2104
+ // Handle range prefix with command (e.g., :1,10d or :%d)
2105
+ // Supported range formats: %, ., $, 'a, line numbers, and combinations with ,
2106
+ let processedCmd = cmd;
2107
+ let range: string | null = null;
2108
+
2109
+ const rangePattern = /^([%.$]|\d+|'[a-z])?(?:,([%.$]|\d+|'[a-z]))?\s*(.*)$/;
2110
+ const rangeMatch = cmd.match(rangePattern);
2111
+ if (rangeMatch && rangeMatch[3]) {
2112
+ // There's a command after the range
2113
+ range = (rangeMatch[1] || "") + (rangeMatch[2] ? "," + rangeMatch[2] : "");
2114
+ processedCmd = rangeMatch[3];
2115
+ }
2116
+
2117
+ // Handle special commands that start with symbols
2118
+ if (processedCmd.startsWith("!")) {
2119
+ // Shell command - not implemented
2120
+ return { error: editor.t("error.shell_not_supported") };
2121
+ }
2122
+
2123
+ // Handle +cmd syntax for :e +10 file (open file at line 10)
2124
+ let plusCmd: string | null = null;
2125
+ if (processedCmd.startsWith("+")) {
2126
+ const plusMatch = processedCmd.match(/^\+(\S*)\s*(.*)/);
2127
+ if (plusMatch) {
2128
+ plusCmd = plusMatch[1] || "$"; // + alone means go to end
2129
+ processedCmd = plusMatch[2];
2130
+ }
2131
+ }
2132
+
2133
+ // Split command into command name and arguments
2134
+ // Supports: cmd, cmd!, cmd args, cmd! args
2135
+ const match = processedCmd.match(/^([a-zA-Z]\w*)(!)?(?:\s+(.*))?$/);
2136
+ if (!match) {
2137
+ // Maybe it's just a command name without arguments
2138
+ if (processedCmd.match(/^[a-zA-Z]+$/)) {
2139
+ const cmdDef = findCommand(processedCmd);
2140
+ if (cmdDef) {
2141
+ return executeCommand(cmdDef.name, false, null, range);
2142
+ }
2143
+ }
2144
+ return { error: editor.t("error.not_valid_command", { cmd: processedCmd }) };
2145
+ }
2146
+
2147
+ const [, commandInput, bang, args] = match;
2148
+ const force = bang === "!";
2149
+
2150
+ // Look up the command
2151
+ const cmdDef = findCommand(commandInput);
2152
+ if (!cmdDef) {
2153
+ return { error: editor.t("error.unknown_command", { cmd: commandInput }) };
2154
+ }
2155
+
2156
+ // Validate bang usage
2157
+ if (force && !cmdDef.allowBang) {
2158
+ return { error: editor.t("error.command_no_bang", { cmd: cmdDef.name }) };
2159
+ }
2160
+
2161
+ // Execute the command
2162
+ return executeCommand(cmdDef.name, force, args || null, range);
2163
+ }
2164
+
2165
+ // Execute a resolved command
2166
+ async function executeCommand(
2167
+ command: string,
2168
+ force: boolean,
2169
+ args: string | null,
2170
+ _range: string | null // Range support is limited for now
2171
+ ): Promise<CommandResult> {
2172
+
2173
+ switch (command) {
2174
+ case "write": {
2175
+ // :w - save current file
2176
+ // :w filename - save as filename (not implemented yet)
2177
+ if (args) {
2178
+ return { error: editor.t("error.save_as_not_implemented") };
2179
+ }
2180
+ editor.executeAction("save");
2181
+ return { message: editor.t("status.file_saved") };
2182
+ }
2183
+
2184
+ case "quit": {
2185
+ // :q - quit (close buffer)
2186
+ // :q! - force quit (discard changes)
2187
+ const bufferId = editor.getActiveBufferId();
2188
+ if (!force && editor.isBufferModified(bufferId)) {
2189
+ return { error: editor.t("error.no_write_since_change", { cmd: ":q!" }) };
2190
+ }
2191
+ editor.executeAction("close_buffer");
2192
+ return {};
2193
+ }
2194
+
2195
+ case "wq":
2196
+ case "xit":
2197
+ case "exit": {
2198
+ // :wq or :x - save and quit
2199
+ editor.executeAction("save");
2200
+ editor.executeAction("close_buffer");
2201
+ return {};
2202
+ }
2203
+
2204
+ case "wall": {
2205
+ // :wa - save all buffers
2206
+ editor.executeAction("save_all");
2207
+ return { message: editor.t("status.all_files_saved") };
2208
+ }
2209
+
2210
+ case "qall": {
2211
+ // :qa - quit all
2212
+ // :qa! - force quit all
2213
+ if (force) {
2214
+ editor.executeAction("quit_all");
2215
+ } else {
2216
+ // Check if any buffer is modified
2217
+ const buffers = editor.listBuffers();
2218
+ for (const buf of buffers) {
2219
+ if (buf.modified) {
2220
+ return { error: editor.t("error.no_write_since_change", { cmd: ":qa!" }) };
2221
+ }
2222
+ }
2223
+ editor.executeAction("quit_all");
2224
+ }
2225
+ return {};
2226
+ }
2227
+
2228
+ case "wqall": {
2229
+ // :wqa or :xa - save all and quit
2230
+ editor.executeAction("save_all");
2231
+ editor.executeAction("quit_all");
2232
+ return {};
2233
+ }
2234
+
2235
+ case "edit": {
2236
+ // :e - reload current file
2237
+ // :e filename - open file
2238
+ // :e! - force reload (discard changes)
2239
+ if (!args) {
2240
+ if (force) {
2241
+ editor.executeAction("revert");
2242
+ return { message: editor.t("status.file_reverted_discarded") };
2243
+ }
2244
+ const bufferId = editor.getActiveBufferId();
2245
+ if (editor.isBufferModified(bufferId)) {
2246
+ return { error: editor.t("error.no_write_since_change", { cmd: ":e!" }) };
2247
+ }
2248
+ editor.executeAction("revert");
2249
+ return { message: editor.t("status.file_reverted") };
2250
+ }
2251
+ // Open the specified file
2252
+ const path = args.trim();
2253
+ editor.openFile(path, 0, 0);
2254
+ return {};
2255
+ }
2256
+
2257
+ case "enew": {
2258
+ // :enew - create new buffer in current split
2259
+ if (!force) {
2260
+ const bufferId = editor.getActiveBufferId();
2261
+ if (editor.isBufferModified(bufferId)) {
2262
+ return { error: editor.t("error.no_write_since_change", { cmd: ":enew!" }) };
2263
+ }
2264
+ }
2265
+ editor.executeAction("new_buffer");
2266
+ return {};
2267
+ }
2268
+
2269
+ case "revert": {
2270
+ // :revert - Fresh-specific command to reload file
2271
+ editor.executeAction("revert");
2272
+ return { message: editor.t("status.file_reverted") };
2273
+ }
2274
+
2275
+ case "next": {
2276
+ // :n - next buffer
2277
+ editor.executeAction("next_buffer");
2278
+ return {};
2279
+ }
2280
+
2281
+ case "previous": {
2282
+ // :prev - previous buffer
2283
+ editor.executeAction("prev_buffer");
2284
+ return {};
2285
+ }
2286
+
2287
+ case "bnext": {
2288
+ // :bn - next buffer
2289
+ editor.executeAction("next_buffer");
2290
+ return {};
2291
+ }
2292
+
2293
+ case "bprevious": {
2294
+ // :bp - previous buffer
2295
+ editor.executeAction("prev_buffer");
2296
+ return {};
2297
+ }
2298
+
2299
+ case "bdelete": {
2300
+ // :bd - delete buffer (close)
2301
+ // :bd! - force close even if modified
2302
+ const bufferId = editor.getActiveBufferId();
2303
+ if (!force && editor.isBufferModified(bufferId)) {
2304
+ return { error: editor.t("error.no_write_since_change", { cmd: ":bd!" }) };
2305
+ }
2306
+ editor.executeAction("close_buffer");
2307
+ return {};
2308
+ }
2309
+
2310
+ case "buffer": {
2311
+ // :b [N] - go to buffer N
2312
+ // :b name - go to buffer matching name
2313
+ if (!args) {
2314
+ // Show current buffer info
2315
+ const bufferId = editor.getActiveBufferId();
2316
+ const info = editor.getBufferInfo(bufferId);
2317
+ if (info) {
2318
+ const name = info.path ? editor.pathBasename(info.path) : editor.t("info.no_name");
2319
+ return { message: editor.t("info.buffer", { id: String(info.id), name }) };
2320
+ }
2321
+ return {};
2322
+ }
2323
+ // Try to parse as buffer number
2324
+ const bufNum = parseInt(args.trim(), 10);
2325
+ if (!isNaN(bufNum)) {
2326
+ const buffers = editor.listBuffers();
2327
+ const target = buffers.find(b => b.id === bufNum);
2328
+ if (target) {
2329
+ editor.showBuffer(target.id);
2330
+ return {};
2331
+ }
2332
+ return { error: editor.t("error.buffer_not_found", { id: String(bufNum) }) };
2333
+ }
2334
+ // Try to match buffer by name
2335
+ const buffers = editor.listBuffers();
2336
+ const pattern = args.trim().toLowerCase();
2337
+ const matches = buffers.filter(b => {
2338
+ const name = b.path ? editor.pathBasename(b.path).toLowerCase() : "";
2339
+ return name.includes(pattern);
2340
+ });
2341
+ if (matches.length === 1) {
2342
+ editor.showBuffer(matches[0].id);
2343
+ return {};
2344
+ } else if (matches.length > 1) {
2345
+ return { error: editor.t("error.multiple_buffers_match", { pattern: args }) };
2346
+ }
2347
+ return { error: editor.t("error.no_buffer_matching", { pattern: args }) };
2348
+ }
2349
+
2350
+ case "buffers":
2351
+ case "ls":
2352
+ case "files": {
2353
+ // :ls - list buffers
2354
+ const buffers = editor.listBuffers();
2355
+ const lines = buffers.map(buf => {
2356
+ const modified = buf.modified ? " [+]" : "";
2357
+ const current = buf.id === editor.getActiveBufferId() ? "%" : " ";
2358
+ const name = buf.path ? editor.pathBasename(buf.path) : editor.t("info.no_name");
2359
+ return `${current}${buf.id}: ${name}${modified}`;
2360
+ });
2361
+ return { message: lines.join(" | ") || editor.t("info.no_buffers") };
2362
+ }
2363
+
2364
+ case "split": {
2365
+ // :sp - horizontal split
2366
+ editor.executeAction("split_horizontal");
2367
+ if (args) {
2368
+ // Open file in new split
2369
+ const path = args.trim();
2370
+ editor.openFile(path, 0, 0);
2371
+ }
2372
+ return {};
2373
+ }
2374
+
2375
+ case "vsplit": {
2376
+ // :vs - vertical split
2377
+ editor.executeAction("split_vertical");
2378
+ if (args) {
2379
+ // Open file in new split
2380
+ const path = args.trim();
2381
+ editor.openFile(path, 0, 0);
2382
+ }
2383
+ return {};
2384
+ }
2385
+
2386
+ case "new": {
2387
+ // :new - create new buffer in horizontal split
2388
+ editor.executeAction("split_horizontal");
2389
+ editor.executeAction("new_buffer");
2390
+ if (args) {
2391
+ const path = args.trim();
2392
+ editor.openFile(path, 0, 0);
2393
+ }
2394
+ return {};
2395
+ }
2396
+
2397
+ case "vnew": {
2398
+ // :vnew - create new buffer in vertical split
2399
+ editor.executeAction("split_vertical");
2400
+ editor.executeAction("new_buffer");
2401
+ if (args) {
2402
+ const path = args.trim();
2403
+ editor.openFile(path, 0, 0);
2404
+ }
2405
+ return {};
2406
+ }
2407
+
2408
+ case "only": {
2409
+ // :only - close all other splits
2410
+ editor.executeAction("close_other_splits");
2411
+ return {};
2412
+ }
2413
+
2414
+ case "close": {
2415
+ // :close - close current split (same as :q for Fresh)
2416
+ const bufferId = editor.getActiveBufferId();
2417
+ if (!force && editor.isBufferModified(bufferId)) {
2418
+ return { error: editor.t("error.no_write_since_change", { cmd: ":close!" }) };
2419
+ }
2420
+ editor.executeAction("close_buffer");
2421
+ return {};
2422
+ }
2423
+
2424
+ case "tabnew":
2425
+ case "tabedit": {
2426
+ // :tabnew - new tab (creates new buffer in Fresh)
2427
+ editor.executeAction("new_buffer");
2428
+ if (args) {
2429
+ const path = args.trim();
2430
+ editor.openFile(path, 0, 0);
2431
+ }
2432
+ return {};
2433
+ }
2434
+
2435
+ case "tabclose": {
2436
+ // :tabclose - close current tab/buffer
2437
+ const bufferId = editor.getActiveBufferId();
2438
+ if (!force && editor.isBufferModified(bufferId)) {
2439
+ return { error: editor.t("error.no_write_since_change", { cmd: ":tabclose!" }) };
2440
+ }
2441
+ editor.executeAction("close_buffer");
2442
+ return {};
2443
+ }
2444
+
2445
+ case "tabnext": {
2446
+ // :tabnext - next tab/buffer
2447
+ editor.executeAction("next_buffer");
2448
+ return {};
2449
+ }
2450
+
2451
+ case "tabprevious": {
2452
+ // :tabprev - previous tab/buffer
2453
+ editor.executeAction("prev_buffer");
2454
+ return {};
2455
+ }
2456
+
2457
+ case "copen": {
2458
+ // :copen - open diagnostics panel (Fresh equivalent)
2459
+ editor.executeAction("show_diagnostics");
2460
+ return {};
2461
+ }
2462
+
2463
+ case "cclose": {
2464
+ // :cclose - close diagnostics panel
2465
+ return { message: editor.t("info.close_diagnostics") };
2466
+ }
2467
+
2468
+ case "cnext": {
2469
+ // :cnext - next diagnostic
2470
+ editor.executeAction("goto_next_diagnostic");
2471
+ return {};
2472
+ }
2473
+
2474
+ case "cprevious": {
2475
+ // :cprev - previous diagnostic
2476
+ editor.executeAction("goto_prev_diagnostic");
2477
+ return {};
2478
+ }
2479
+
2480
+ case "cfirst": {
2481
+ // :cfirst - first diagnostic
2482
+ editor.executeAction("goto_first_diagnostic");
2483
+ return {};
2484
+ }
2485
+
2486
+ case "clast": {
2487
+ // :clast - last diagnostic
2488
+ editor.executeAction("goto_last_diagnostic");
2489
+ return {};
2490
+ }
2491
+
2492
+ case "nohlsearch": {
2493
+ // :noh - clear search highlighting
2494
+ editor.executeAction("clear_search");
2495
+ return {};
2496
+ }
2497
+
2498
+ case "substitute": {
2499
+ // :s - substitute (not implemented)
2500
+ // This would require parsing /pattern/replacement/flags
2501
+ return { error: editor.t("error.substitute_not_implemented") };
2502
+ }
2503
+
2504
+ case "global":
2505
+ case "vglobal": {
2506
+ // :g - global command (not implemented)
2507
+ return { error: editor.t("error.global_not_implemented") };
2508
+ }
2509
+
2510
+ case "undo": {
2511
+ // :undo - undo
2512
+ editor.executeAction("undo");
2513
+ return {};
2514
+ }
2515
+
2516
+ case "redo": {
2517
+ // :redo - redo
2518
+ editor.executeAction("redo");
2519
+ return {};
2520
+ }
2521
+
2522
+ case "set": {
2523
+ // :set - set options (limited implementation)
2524
+ if (!args) {
2525
+ return { error: editor.t("error.set_usage") };
2526
+ }
2527
+ return handleSetCommand(args);
2528
+ }
2529
+
2530
+ case "pwd": {
2531
+ // :pwd - print working directory
2532
+ const cwd = editor.getCwd();
2533
+ return { message: cwd };
2534
+ }
2535
+
2536
+ case "cd": {
2537
+ // :cd - change directory (info only, can't actually change)
2538
+ if (!args) {
2539
+ return { message: editor.getCwd() };
2540
+ }
2541
+ return { error: editor.t("error.cannot_change_directory") };
2542
+ }
2543
+
2544
+ case "file": {
2545
+ // :f - show current file info
2546
+ // :f name - rename current buffer (not implemented)
2547
+ if (args) {
2548
+ return { error: editor.t("error.rename_not_implemented") };
2549
+ }
2550
+ const bufferId = editor.getActiveBufferId();
2551
+ const info = editor.getBufferInfo(bufferId);
2552
+ if (info) {
2553
+ const modified = info.modified ? editor.t("info.modified") : "";
2554
+ const path = info.path || editor.t("info.no_name");
2555
+ const line = editor.getCursorLine();
2556
+ return { message: editor.t("info.file", { path, modified, line: String(line), bytes: String(info.length) }) };
2557
+ }
2558
+ return { error: editor.t("error.no_buffer") };
2559
+ }
2560
+
2561
+ case "help": {
2562
+ // :help - show help
2563
+ if (args) {
2564
+ return { message: editor.t("info.help_not_available", { topic: args }) };
2565
+ }
2566
+ return {
2567
+ message: editor.t("info.help_commands")
2568
+ };
2569
+ }
2570
+
2571
+ case "version": {
2572
+ // :version - show version
2573
+ return { message: editor.t("info.version") };
2574
+ }
2575
+
2576
+ case "marks": {
2577
+ // :marks - show marks (not implemented)
2578
+ return { error: editor.t("error.marks_not_implemented") };
2579
+ }
2580
+
2581
+ case "registers": {
2582
+ // :registers - show registers (not implemented)
2583
+ return { error: editor.t("error.registers_not_implemented") };
2584
+ }
2585
+
2586
+ case "jumps": {
2587
+ // :jumps - show jump list (not implemented)
2588
+ return { error: editor.t("error.jump_list_not_implemented") };
2589
+ }
2590
+
2591
+ case "syntax": {
2592
+ // :syntax - syntax info
2593
+ if (args === "off") {
2594
+ return { error: editor.t("error.syntax_cannot_disable") };
2595
+ }
2596
+ return { message: editor.t("status.syntax_always_on") };
2597
+ }
2598
+
2599
+ case "read": {
2600
+ // :r - read file into buffer (not implemented)
2601
+ return { error: editor.t("error.read_not_implemented") };
2602
+ }
2603
+
2604
+ case "saveas": {
2605
+ // :saveas - save as (not implemented)
2606
+ return { error: editor.t("error.saveas_not_implemented") };
2607
+ }
2608
+
2609
+ case "grep":
2610
+ case "vimgrep": {
2611
+ // :grep - search (use Fresh's grep)
2612
+ if (args) {
2613
+ // Could potentially pass args to search, but for now just open search
2614
+ editor.executeAction("search");
2615
+ return { message: editor.t("info.use_search_dialog", { pattern: args }) };
2616
+ }
2617
+ editor.executeAction("search");
2618
+ return {};
2619
+ }
2620
+
2621
+ case "make": {
2622
+ // :make - run build command (not implemented)
2623
+ return { error: editor.t("error.use_terminal") };
2624
+ }
2625
+
2626
+ case "ascii": {
2627
+ // :ascii - show ASCII value of char under cursor
2628
+ return { message: editor.t("info.status_bar_char") };
2629
+ }
2630
+
2631
+ default: {
2632
+ return { error: editor.t("error.unknown_command", { cmd: command }) };
2633
+ }
2634
+ }
2635
+ }
2636
+
2637
+ // Go to a specific line number
2638
+ async function gotoLine(lineNum: number): Promise<CommandResult> {
2639
+ if (lineNum < 1) {
2640
+ return { error: editor.t("error.line_must_be_positive") };
2641
+ }
2642
+
2643
+ const bufferId = editor.getActiveBufferId();
2644
+ const bufferLength = editor.getBufferLength(bufferId);
2645
+
2646
+ // Get the text to find the line offset
2647
+ const text = await editor.getBufferText(bufferId, 0, bufferLength);
2648
+ if (!text) {
2649
+ return { error: editor.t("error.cannot_read_buffer") };
2650
+ }
2651
+
2652
+ let lineStart = 0;
2653
+ let currentLine = 1;
2654
+
2655
+ for (let i = 0; i < text.length && currentLine < lineNum; i++) {
2656
+ if (text[i] === '\n') {
2657
+ currentLine++;
2658
+ lineStart = i + 1;
2659
+ }
2660
+ }
2661
+
2662
+ if (currentLine >= lineNum || lineStart < text.length) {
2663
+ editor.setBufferCursor(bufferId, lineStart);
2664
+ return {};
2665
+ }
2666
+
2667
+ // If requested line is beyond file, go to last line
2668
+ editor.executeAction("move_document_end");
2669
+ return { message: editor.t("status.line_beyond_end", { line: String(lineNum) }) };
2670
+ }
2671
+
2672
+ // Handle :set command options
2673
+ function handleSetCommand(args: string): CommandResult {
2674
+ const parts = args.split("=");
2675
+ const option = parts[0].trim();
2676
+ const value = parts.length > 1 ? parts[1].trim() : null;
2677
+
2678
+ switch (option) {
2679
+ case "number":
2680
+ case "nu": {
2681
+ // :set number - show line numbers
2682
+ const bufferId = editor.getActiveBufferId();
2683
+ editor.setLineNumbers(bufferId, true);
2684
+ return { message: editor.t("status.line_numbers_on") };
2685
+ }
2686
+
2687
+ case "nonumber":
2688
+ case "nonu": {
2689
+ // :set nonumber - hide line numbers
2690
+ const bufferId = editor.getActiveBufferId();
2691
+ editor.setLineNumbers(bufferId, false);
2692
+ return { message: editor.t("status.line_numbers_off") };
2693
+ }
2694
+
2695
+ case "wrap": {
2696
+ // :set wrap - enable line wrap
2697
+ editor.executeAction("toggle_wrap");
2698
+ return { message: editor.t("status.line_wrap_toggled") };
2699
+ }
2700
+
2701
+ case "nowrap": {
2702
+ // :set nowrap - disable line wrap
2703
+ editor.executeAction("toggle_wrap");
2704
+ return { message: editor.t("status.line_wrap_toggled") };
2705
+ }
2706
+
2707
+ default: {
2708
+ return { error: editor.t("error.unknown_option", { option }) };
2709
+ }
2710
+ }
2711
+ }
2712
+
2713
+ // Register event handler for prompt confirmation
2714
+ editor.on("prompt_confirmed", "vi_command_handler");
2715
+
1599
2716
  // ============================================================================
1600
2717
  // Toggle Command
1601
2718
  // ============================================================================
@@ -1607,18 +2724,18 @@ globalThis.vi_mode_toggle = function (): void {
1607
2724
 
1608
2725
  if (viModeEnabled) {
1609
2726
  switchMode("normal");
1610
- editor.setStatus("Vi mode enabled - NORMAL");
2727
+ editor.setStatus(editor.t("status.enabled"));
1611
2728
  } else {
1612
2729
  editor.setEditorMode(null);
1613
2730
  state.mode = "normal";
1614
2731
  state.pendingOperator = null;
1615
- editor.setStatus("Vi mode disabled");
2732
+ editor.setStatus(editor.t("status.disabled"));
1616
2733
  }
1617
2734
  };
1618
2735
 
1619
2736
  editor.registerCommand(
1620
- "Toggle Vi mode",
1621
- "Enable or disable vi-style modal editing",
2737
+ "%cmd.toggle_vi_mode",
2738
+ "%cmd.toggle_vi_mode_desc",
1622
2739
  "vi_mode_toggle",
1623
2740
  "normal",
1624
2741
  );
@@ -1627,4 +2744,4 @@ editor.registerCommand(
1627
2744
  // Initialization
1628
2745
  // ============================================================================
1629
2746
 
1630
- editor.setStatus("Vi mode plugin loaded. Use 'Toggle Vi mode' command to enable.");
2747
+ editor.setStatus(editor.t("status.loaded"));