ruvim 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +84 -0
  4. data/CLAUDE.md +1 -0
  5. data/docs/binding.md +23 -0
  6. data/docs/command.md +85 -0
  7. data/docs/config.md +2 -2
  8. data/docs/done.md +21 -0
  9. data/docs/spec.md +157 -12
  10. data/docs/todo.md +1 -5
  11. data/docs/vim_diff.md +94 -172
  12. data/lib/ruvim/app.rb +882 -69
  13. data/lib/ruvim/buffer.rb +35 -1
  14. data/lib/ruvim/cli.rb +12 -3
  15. data/lib/ruvim/clipboard.rb +2 -0
  16. data/lib/ruvim/command_invocation.rb +3 -1
  17. data/lib/ruvim/command_line.rb +2 -0
  18. data/lib/ruvim/command_registry.rb +2 -0
  19. data/lib/ruvim/config_dsl.rb +2 -0
  20. data/lib/ruvim/config_loader.rb +2 -0
  21. data/lib/ruvim/context.rb +2 -0
  22. data/lib/ruvim/dispatcher.rb +143 -13
  23. data/lib/ruvim/display_width.rb +3 -0
  24. data/lib/ruvim/editor.rb +455 -71
  25. data/lib/ruvim/ex_command_registry.rb +2 -0
  26. data/lib/ruvim/global_commands.rb +890 -63
  27. data/lib/ruvim/highlighter.rb +16 -21
  28. data/lib/ruvim/input.rb +39 -28
  29. data/lib/ruvim/keymap_manager.rb +83 -0
  30. data/lib/ruvim/keyword_chars.rb +2 -0
  31. data/lib/ruvim/lang/base.rb +25 -0
  32. data/lib/ruvim/lang/csv.rb +18 -0
  33. data/lib/ruvim/lang/json.rb +18 -0
  34. data/lib/ruvim/lang/markdown.rb +170 -0
  35. data/lib/ruvim/lang/ruby.rb +236 -0
  36. data/lib/ruvim/lang/scheme.rb +44 -0
  37. data/lib/ruvim/lang/tsv.rb +19 -0
  38. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  39. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  40. data/lib/ruvim/rich_view.rb +93 -0
  41. data/lib/ruvim/screen.rb +503 -106
  42. data/lib/ruvim/terminal.rb +18 -1
  43. data/lib/ruvim/text_metrics.rb +2 -0
  44. data/lib/ruvim/version.rb +1 -1
  45. data/lib/ruvim/window.rb +2 -0
  46. data/lib/ruvim.rb +14 -0
  47. data/test/app_completion_test.rb +73 -0
  48. data/test/app_dot_repeat_test.rb +13 -0
  49. data/test/app_motion_test.rb +13 -0
  50. data/test/app_scenario_test.rb +729 -1
  51. data/test/app_startup_test.rb +187 -0
  52. data/test/arglist_test.rb +113 -0
  53. data/test/buffer_test.rb +49 -30
  54. data/test/dispatcher_test.rb +322 -0
  55. data/test/editor_register_test.rb +23 -0
  56. data/test/highlighter_test.rb +121 -0
  57. data/test/indent_test.rb +201 -0
  58. data/test/input_screen_integration_test.rb +40 -2
  59. data/test/markdown_renderer_test.rb +279 -0
  60. data/test/on_save_hook_test.rb +150 -0
  61. data/test/rich_view_test.rb +478 -0
  62. data/test/screen_test.rb +304 -0
  63. metadata +33 -2
data/lib/ruvim/app.rb CHANGED
@@ -1,13 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
1
6
  module RuVim
2
7
  class App
3
- def initialize(path: nil, paths: nil, stdin: STDIN, stdout: STDOUT, pre_config_actions: [], startup_actions: [], clean: false, skip_user_config: false, config_path: nil, readonly: false, diff_mode: false, quickfix_errorfile: nil, session_file: nil, nomodifiable: false, restricted: false, verbose_level: 0, verbose_io: STDERR, startup_time_path: nil, startup_open_layout: nil, startup_open_count: nil)
8
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES = 64 * 1024 * 1024
9
+ LARGE_FILE_STAGED_PREFIX_BYTES = 8 * 1024 * 1024
10
+ ASYNC_FILE_READ_CHUNK_BYTES = 1 * 1024 * 1024
11
+ ASYNC_FILE_EVENT_FLUSH_BYTES = 4 * 1024 * 1024
12
+
13
+ def initialize(path: nil, paths: nil, stdin: STDIN, ui_stdin: nil, stdin_stream_mode: false, stdout: STDOUT, pre_config_actions: [], startup_actions: [], clean: false, skip_user_config: false, config_path: nil, readonly: false, diff_mode: false, quickfix_errorfile: nil, session_file: nil, nomodifiable: false, restricted: false, verbose_level: 0, verbose_io: STDERR, startup_time_path: nil, startup_open_layout: nil, startup_open_count: nil)
14
+ startup_paths = Array(paths || path).compact
15
+ @ui_stdin = ui_stdin || stdin
16
+ @stdin_stream_mode = !!stdin_stream_mode
17
+ @stdin_stream_source = @stdin_stream_mode ? stdin : nil
4
18
  @editor = Editor.new
5
- @terminal = Terminal.new(stdin:, stdout:)
6
- @input = Input.new(stdin:)
19
+ @terminal = Terminal.new(stdin: @ui_stdin, stdout:)
20
+ @input = Input.new(@ui_stdin)
7
21
  @screen = Screen.new(terminal: @terminal)
8
22
  @dispatcher = Dispatcher.new
9
23
  @keymaps = KeymapManager.new
10
24
  @signal_r, @signal_w = IO.pipe
25
+ @stream_event_queue = nil
26
+ @stream_reader_thread = nil
27
+ @stream_buffer_id = nil
28
+ @stream_stop_requested = false
29
+ @async_file_loads = {}
11
30
  @cmdline_history = Hash.new { |h, k| h[k] = [] }
12
31
  @cmdline_history_index = nil
13
32
  @cmdline_completion = nil
@@ -33,6 +52,11 @@ module RuVim
33
52
  @startup_open_layout = startup_open_layout
34
53
  @startup_open_count = startup_open_count
35
54
  @editor.restricted_mode = @restricted_mode
55
+ @editor.stdin_stream_stop_handler = method(:stdin_stream_stop_command)
56
+ @editor.open_path_handler = method(:open_path_with_large_file_support)
57
+ @editor.keymap_manager = @keymaps
58
+ @editor.app_action_handler = method(:handle_editor_app_action)
59
+ load_command_line_history!
36
60
 
37
61
  startup_mark("init.start")
38
62
  register_builtins!
@@ -48,8 +72,10 @@ module RuVim
48
72
  install_signal_handlers
49
73
  startup_mark("signals.installed")
50
74
 
51
- startup_paths = Array(paths || path).compact
52
- if startup_paths.empty?
75
+ if @stdin_stream_mode && startup_paths.empty?
76
+ verbose_log(1, "startup: stdin stream buffer")
77
+ prepare_stdin_stream_buffer!
78
+ elsif startup_paths.empty?
53
79
  verbose_log(1, "startup: intro")
54
80
  @editor.show_intro_buffer_if_applicable!
55
81
  else
@@ -64,12 +90,14 @@ module RuVim
64
90
  verbose_log(1, "startup: run_startup_actions count=#{Array(startup_actions).length}")
65
91
  run_startup_actions!(startup_actions)
66
92
  startup_mark("startup_actions.done")
93
+ start_stdin_stream_reader! if @stream_buffer_id
67
94
  write_startuptime_log!
68
95
  end
69
96
 
70
97
  def run
71
98
  @terminal.with_ui do
72
99
  loop do
100
+ @needs_redraw = true if drain_stream_events!
73
101
  if @needs_redraw
74
102
  @screen.render(@editor)
75
103
  @needs_redraw = false
@@ -89,8 +117,18 @@ module RuVim
89
117
 
90
118
  handle_key(key)
91
119
  @needs_redraw = true
120
+
121
+ # Batch insert-mode keystrokes to avoid per-char rendering during paste
122
+ while @editor.mode == :insert && @input.has_pending_input?
123
+ batch_key = @input.read_key(timeout: 0, esc_timeout: 0)
124
+ break unless batch_key
125
+ handle_key(batch_key)
126
+ end
92
127
  end
93
128
  end
129
+ ensure
130
+ shutdown_background_readers!
131
+ save_command_line_history!
94
132
  end
95
133
 
96
134
  def run_startup_actions!(actions, log_prefix: "startup")
@@ -173,6 +211,9 @@ module RuVim
173
211
  register_internal_unless(cmd, "cursor.page_down.half", call: :cursor_page_down_half, desc: "Move half page down")
174
212
  register_internal_unless(cmd, "window.scroll_up.line", call: :window_scroll_up_line, desc: "Scroll window up one line")
175
213
  register_internal_unless(cmd, "window.scroll_down.line", call: :window_scroll_down_line, desc: "Scroll window down one line")
214
+ register_internal_unless(cmd, "window.cursor_line_top", call: :window_cursor_line_top, desc: "Put cursor line at top")
215
+ register_internal_unless(cmd, "window.cursor_line_center", call: :window_cursor_line_center, desc: "Put cursor line at center")
216
+ register_internal_unless(cmd, "window.cursor_line_bottom", call: :window_cursor_line_bottom, desc: "Put cursor line at bottom")
176
217
  register_internal_unless(cmd, "cursor.line_start", call: :cursor_line_start, desc: "Move to column 1")
177
218
  register_internal_unless(cmd, "cursor.line_end", call: :cursor_line_end, desc: "Move to end of line")
178
219
  register_internal_unless(cmd, "cursor.first_nonblank", call: :cursor_first_nonblank, desc: "Move to first nonblank")
@@ -198,10 +239,17 @@ module RuVim
198
239
  register_internal_unless(cmd, "window.focus_right", call: :window_focus_right, desc: "Focus right window")
199
240
  register_internal_unless(cmd, "window.focus_up", call: :window_focus_up, desc: "Focus upper window")
200
241
  register_internal_unless(cmd, "window.focus_down", call: :window_focus_down, desc: "Focus lower window")
242
+ register_internal_unless(cmd, "window.focus_or_split_left", call: :window_focus_or_split_left, desc: "Focus left window or split")
243
+ register_internal_unless(cmd, "window.focus_or_split_right", call: :window_focus_or_split_right, desc: "Focus right window or split")
244
+ register_internal_unless(cmd, "window.focus_or_split_up", call: :window_focus_or_split_up, desc: "Focus upper window or split")
245
+ register_internal_unless(cmd, "window.focus_or_split_down", call: :window_focus_or_split_down, desc: "Focus lower window or split")
201
246
  register_internal_unless(cmd, "mode.command_line", call: :enter_command_line_mode, desc: "Enter command-line mode")
202
247
  register_internal_unless(cmd, "mode.search_forward", call: :enter_search_forward_mode, desc: "Enter / search")
203
248
  register_internal_unless(cmd, "mode.search_backward", call: :enter_search_backward_mode, desc: "Enter ? search")
204
249
  register_internal_unless(cmd, "buffer.delete_char", call: :delete_char, desc: "Delete char under cursor")
250
+ register_internal_unless(cmd, "buffer.substitute_char", call: :substitute_char, desc: "Substitute char(s)")
251
+ register_internal_unless(cmd, "buffer.swapcase_char", call: :swapcase_char, desc: "Swap case under cursor")
252
+ register_internal_unless(cmd, "buffer.join_lines", call: :join_lines, desc: "Join lines")
205
253
  register_internal_unless(cmd, "buffer.delete_line", call: :delete_line, desc: "Delete current line")
206
254
  register_internal_unless(cmd, "buffer.delete_motion", call: :delete_motion, desc: "Delete by motion")
207
255
  register_internal_unless(cmd, "buffer.change_motion", call: :change_motion, desc: "Change by motion")
@@ -231,10 +279,39 @@ module RuVim
231
279
  register_internal_unless(cmd, "buffer.replace_char", call: :replace_char, desc: "Replace single char")
232
280
  register_internal_unless(cmd, "file.goto_under_cursor", call: :file_goto_under_cursor, desc: "Open file under cursor")
233
281
  register_internal_unless(cmd, "ui.clear_message", call: :clear_message, desc: "Clear message")
282
+ register_internal_unless(cmd, "normal.register_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_register_pending_start) }, desc: "Select register for next operation")
283
+ register_internal_unless(cmd, "normal.operator_delete_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :delete) }, desc: "Start delete operator")
284
+ register_internal_unless(cmd, "normal.operator_yank_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :yank) }, desc: "Start yank operator")
285
+ register_internal_unless(cmd, "normal.operator_change_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :change) }, desc: "Start change operator")
286
+ register_internal_unless(cmd, "normal.operator_indent_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :indent) }, desc: "Start indent operator")
287
+ register_internal_unless(cmd, "buffer.indent_lines", call: :indent_lines, desc: "Auto-indent lines")
288
+ register_internal_unless(cmd, "buffer.indent_motion", call: :indent_motion, desc: "Auto-indent motion range")
289
+ register_internal_unless(cmd, "buffer.visual_indent", call: :visual_indent, desc: "Auto-indent visual selection")
290
+ register_internal_unless(cmd, "normal.replace_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_replace_pending_start) }, desc: "Start replace-char pending")
291
+ register_internal_unless(cmd, "normal.find_char_forward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "f") }, desc: "Start char find forward")
292
+ register_internal_unless(cmd, "normal.find_char_backward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "F") }, desc: "Start char find backward")
293
+ register_internal_unless(cmd, "normal.find_till_forward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "t") }, desc: "Start till-char find forward")
294
+ register_internal_unless(cmd, "normal.find_till_backward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "T") }, desc: "Start till-char find backward")
295
+ register_internal_unless(cmd, "normal.find_repeat", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_repeat, reverse: false) }, desc: "Repeat last f/t/F/T")
296
+ register_internal_unless(cmd, "normal.find_repeat_reverse", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_repeat, reverse: true) }, desc: "Repeat last f/t/F/T in reverse")
297
+ register_internal_unless(cmd, "normal.change_repeat", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_change_repeat) }, desc: "Repeat last change")
298
+ register_internal_unless(cmd, "normal.macro_record_toggle", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_macro_record_toggle) }, desc: "Start/stop macro recording")
299
+ register_internal_unless(cmd, "normal.macro_play_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_macro_play_pending_start) }, desc: "Start macro play pending")
300
+ register_internal_unless(cmd, "normal.mark_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_mark_pending_start) }, desc: "Start mark set pending")
301
+ register_internal_unless(cmd, "normal.jump_mark_linewise_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_jump_pending_start, linewise: true, repeat_token: "'") }, desc: "Start linewise mark jump pending")
302
+ register_internal_unless(cmd, "normal.jump_mark_exact_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_jump_pending_start, linewise: false, repeat_token: "`") }, desc: "Start exact mark jump pending")
303
+ register_internal_unless(
304
+ cmd,
305
+ "stdin.stream_stop",
306
+ call: ->(ctx, **) { ctx.editor.stdin_stream_stop_or_cancel! },
307
+ desc: "Stop stdin follow stream (or cancel pending state)"
308
+ )
234
309
 
235
310
  register_ex_unless(ex, "w", call: :file_write, aliases: %w[write], desc: "Write current buffer", nargs: :maybe_one, bang: true)
236
311
  register_ex_unless(ex, "q", call: :app_quit, aliases: %w[quit], desc: "Quit", nargs: 0, bang: true)
312
+ register_ex_unless(ex, "qa", call: :app_quit_all, aliases: %w[qall], desc: "Quit all", nargs: 0, bang: true)
237
313
  register_ex_unless(ex, "wq", call: :file_write_quit, desc: "Write and quit", nargs: :maybe_one, bang: true)
314
+ register_ex_unless(ex, "wqa", call: :file_write_quit_all, aliases: %w[wqall xa xall], desc: "Write all and quit", nargs: 0, bang: true)
238
315
  register_ex_unless(ex, "e", call: :file_edit, aliases: %w[edit], desc: "Edit file / reload", nargs: :maybe_one, bang: true)
239
316
  register_ex_unless(ex, "help", call: :ex_help, desc: "Show help / topics", nargs: :any)
240
317
  register_ex_unless(ex, "command", call: :ex_define_command, desc: "Define user command", nargs: :any, bang: true)
@@ -244,7 +321,13 @@ module RuVim
244
321
  register_ex_unless(ex, "bprev", call: :buffer_prev, aliases: %w[bp], desc: "Previous buffer", nargs: 0, bang: true)
245
322
  register_ex_unless(ex, "buffer", call: :buffer_switch, aliases: %w[b], desc: "Switch buffer", nargs: 1, bang: true)
246
323
  register_ex_unless(ex, "bdelete", call: :buffer_delete, aliases: %w[bd], desc: "Delete buffer", nargs: :maybe_one, bang: true)
324
+ register_ex_unless(ex, "args", call: :arglist_show, desc: "Show argument list", nargs: 0)
325
+ register_ex_unless(ex, "next", call: :arglist_next, desc: "Next argument", nargs: 0)
326
+ register_ex_unless(ex, "prev", call: :arglist_prev, desc: "Previous argument", nargs: 0)
327
+ register_ex_unless(ex, "first", call: :arglist_first, desc: "First argument", nargs: 0)
328
+ register_ex_unless(ex, "last", call: :arglist_last, desc: "Last argument", nargs: 0)
247
329
  register_ex_unless(ex, "commands", call: :ex_commands, desc: "List Ex commands", nargs: 0)
330
+ register_ex_unless(ex, "bindings", call: :ex_bindings, desc: "List active key bindings", nargs: :any)
248
331
  register_ex_unless(ex, "set", call: :ex_set, desc: "Set options", nargs: :any)
249
332
  register_ex_unless(ex, "setlocal", call: :ex_setlocal, desc: "Set window/buffer local option", nargs: :any)
250
333
  register_ex_unless(ex, "setglobal", call: :ex_setglobal, desc: "Set global option", nargs: :any)
@@ -253,6 +336,7 @@ module RuVim
253
336
  register_ex_unless(ex, "tabnew", call: :tab_new, desc: "New tab", nargs: :maybe_one)
254
337
  register_ex_unless(ex, "tabnext", call: :tab_next, aliases: %w[tabn], desc: "Next tab", nargs: 0)
255
338
  register_ex_unless(ex, "tabprev", call: :tab_prev, aliases: %w[tabp], desc: "Prev tab", nargs: 0)
339
+ register_ex_unless(ex, "tabs", call: :tab_list, desc: "List tabs", nargs: 0)
256
340
  register_ex_unless(ex, "vimgrep", call: :ex_vimgrep, desc: "Populate quickfix from regex (minimal)", nargs: :any)
257
341
  register_ex_unless(ex, "lvimgrep", call: :ex_lvimgrep, desc: "Populate location list from regex (minimal)", nargs: :any)
258
342
  register_ex_unless(ex, "copen", call: :ex_copen, desc: "Open quickfix list", nargs: 0)
@@ -263,6 +347,15 @@ module RuVim
263
347
  register_ex_unless(ex, "lclose", call: :ex_lclose, desc: "Close location list window", nargs: 0)
264
348
  register_ex_unless(ex, "lnext", call: :ex_lnext, aliases: %w[ln], desc: "Next location item", nargs: 0)
265
349
  register_ex_unless(ex, "lprev", call: :ex_lprev, aliases: %w[lp], desc: "Prev location item", nargs: 0)
350
+ register_ex_unless(ex, "grep", call: :ex_grep, desc: "Search with external grep", nargs: :any)
351
+ register_ex_unless(ex, "lgrep", call: :ex_lgrep, desc: "Search with external grep (location list)", nargs: :any)
352
+ register_ex_unless(ex, "d", call: :ex_delete_lines, aliases: %w[delete], desc: "Delete lines", nargs: :any)
353
+ register_ex_unless(ex, "y", call: :ex_yank_lines, aliases: %w[yank], desc: "Yank lines", nargs: :any)
354
+ register_ex_unless(ex, "rich", call: :ex_rich, desc: "Open/close Rich View", nargs: :maybe_one)
355
+ register_internal_unless(cmd, "rich.toggle", call: :rich_toggle, desc: "Toggle Rich View")
356
+ register_internal_unless(cmd, "quickfix.next", call: :ex_cnext, desc: "Next quickfix item")
357
+ register_internal_unless(cmd, "quickfix.prev", call: :ex_cprev, desc: "Prev quickfix item")
358
+ register_internal_unless(cmd, "quickfix.open", call: :ex_copen, desc: "Open quickfix list")
266
359
  end
267
360
 
268
361
  def bind_default_keys!
@@ -297,10 +390,40 @@ module RuVim
297
390
  @keymaps.bind(:normal, ["<C-w>", "j"], "window.focus_down")
298
391
  @keymaps.bind(:normal, ["<C-w>", "k"], "window.focus_up")
299
392
  @keymaps.bind(:normal, ["<C-w>", "l"], "window.focus_right")
393
+ @keymaps.bind(:normal, ["<S-Left>"], "window.focus_or_split_left")
394
+ @keymaps.bind(:normal, ["<S-Right>"], "window.focus_or_split_right")
395
+ @keymaps.bind(:normal, ["<S-Up>"], "window.focus_or_split_up")
396
+ @keymaps.bind(:normal, ["<S-Down>"], "window.focus_or_split_down")
300
397
  @keymaps.bind(:normal, ":", "mode.command_line")
301
398
  @keymaps.bind(:normal, "/", "mode.search_forward")
302
399
  @keymaps.bind(:normal, "?", "mode.search_backward")
303
400
  @keymaps.bind(:normal, "x", "buffer.delete_char")
401
+ @keymaps.bind(:normal, "X", "buffer.delete_motion", kwargs: { motion: "h" })
402
+ @keymaps.bind(:normal, "s", "buffer.substitute_char")
403
+ @keymaps.bind(:normal, "D", "buffer.delete_motion", kwargs: { motion: "$" })
404
+ @keymaps.bind(:normal, "C", "buffer.change_motion", kwargs: { motion: "$" })
405
+ @keymaps.bind(:normal, "S", "buffer.change_line")
406
+ @keymaps.bind(:normal, "Y", "buffer.yank_line")
407
+ @keymaps.bind(:normal, "J", "buffer.join_lines")
408
+ @keymaps.bind(:normal, "~", "buffer.swapcase_char")
409
+ @keymaps.bind(:normal, "\"", "normal.register_pending_start")
410
+ @keymaps.bind(:normal, "d", "normal.operator_delete_start")
411
+ @keymaps.bind(:normal, "y", "normal.operator_yank_start")
412
+ @keymaps.bind(:normal, "c", "normal.operator_change_start")
413
+ @keymaps.bind(:normal, "=", "normal.operator_indent_start")
414
+ @keymaps.bind(:normal, "r", "normal.replace_pending_start")
415
+ @keymaps.bind(:normal, "f", "normal.find_char_forward_start")
416
+ @keymaps.bind(:normal, "F", "normal.find_char_backward_start")
417
+ @keymaps.bind(:normal, "t", "normal.find_till_forward_start")
418
+ @keymaps.bind(:normal, "T", "normal.find_till_backward_start")
419
+ @keymaps.bind(:normal, ";", "normal.find_repeat")
420
+ @keymaps.bind(:normal, ",", "normal.find_repeat_reverse")
421
+ @keymaps.bind(:normal, ".", "normal.change_repeat")
422
+ @keymaps.bind(:normal, "q", "normal.macro_record_toggle")
423
+ @keymaps.bind(:normal, "@", "normal.macro_play_pending_start")
424
+ @keymaps.bind(:normal, "m", "normal.mark_pending_start")
425
+ @keymaps.bind(:normal, "'", "normal.jump_mark_linewise_pending_start")
426
+ @keymaps.bind(:normal, "`", "normal.jump_mark_exact_pending_start")
304
427
  @keymaps.bind(:normal, "p", "buffer.paste_after")
305
428
  @keymaps.bind(:normal, "P", "buffer.paste_before")
306
429
  @keymaps.bind(:normal, "u", "buffer.undo")
@@ -313,6 +436,10 @@ module RuVim
313
436
  @keymaps.bind(:normal, ["<C-b>"], "cursor.page_up.default")
314
437
  @keymaps.bind(:normal, ["<C-e>"], "window.scroll_down.line")
315
438
  @keymaps.bind(:normal, ["<C-y>"], "window.scroll_up.line")
439
+ @keymaps.bind(:normal, "zt", "window.cursor_line_top")
440
+ @keymaps.bind(:normal, "zz", "window.cursor_line_center")
441
+ @keymaps.bind(:normal, "zb", "window.cursor_line_bottom")
442
+ @keymaps.bind(:normal, ["<C-c>"], "stdin.stream_stop")
316
443
  @keymaps.bind(:normal, "n", "search.next")
317
444
  @keymaps.bind(:normal, "N", "search.prev")
318
445
  @keymaps.bind(:normal, "*", "search.word_forward")
@@ -320,6 +447,10 @@ module RuVim
320
447
  @keymaps.bind(:normal, "g*", "search.word_forward_partial")
321
448
  @keymaps.bind(:normal, "g#", "search.word_backward_partial")
322
449
  @keymaps.bind(:normal, "gf", "file.goto_under_cursor")
450
+ @keymaps.bind(:normal, "gr", "rich.toggle")
451
+ @keymaps.bind(:normal, "Q", "quickfix.open")
452
+ @keymaps.bind(:normal, ["]", "q"], "quickfix.next")
453
+ @keymaps.bind(:normal, ["[", "q"], "quickfix.prev")
323
454
  @keymaps.bind(:normal, ["<PageUp>"], "cursor.page_up.default")
324
455
  @keymaps.bind(:normal, ["<PageDown>"], "cursor.page_down.default")
325
456
  @keymaps.bind(:normal, "\e", "ui.clear_message")
@@ -330,7 +461,12 @@ module RuVim
330
461
  clear_stale_message_before_key(key)
331
462
  @skip_record_for_current_key = false
332
463
  append_dot_change_capture_key(key)
333
- if key == :ctrl_c
464
+ if key == :ctrl_z
465
+ suspend_to_shell
466
+ track_mode_transition(mode_before)
467
+ return
468
+ end
469
+ if key == :ctrl_c && @editor.mode != :normal
334
470
  handle_ctrl_c
335
471
  track_mode_transition(mode_before)
336
472
  record_macro_key_if_needed(key)
@@ -338,23 +474,30 @@ module RuVim
338
474
  end
339
475
 
340
476
  case @editor.mode
477
+ when :hit_enter
478
+ handle_hit_enter_key(key)
341
479
  when :insert
342
480
  handle_insert_key(key)
343
481
  when :command_line
344
482
  handle_command_line_key(key)
345
483
  when :visual_char, :visual_line, :visual_block
346
484
  handle_visual_key(key)
485
+ when :rich
486
+ handle_rich_key(key)
347
487
  else
348
488
  handle_normal_key(key)
349
489
  end
350
490
  track_mode_transition(mode_before)
351
491
  load_current_ftplugin!
352
492
  record_macro_key_if_needed(key)
493
+ rescue RuVim::CommandError => e
494
+ @editor.echo_error(e.message)
353
495
  end
354
496
 
355
497
  def clear_stale_message_before_key(key)
356
498
  return if @editor.message.to_s.empty?
357
499
  return if @editor.command_line_active?
500
+ return if @editor.hit_enter_active?
358
501
 
359
502
  # Keep the error visible while the user is still dismissing/cancelling;
360
503
  # otherwise, the next operation replaces the command-line area naturally.
@@ -363,6 +506,46 @@ module RuVim
363
506
  @editor.clear_message
364
507
  end
365
508
 
509
+ def handle_editor_app_action(name, **kwargs)
510
+ if @editor.rich_mode?
511
+ case name.to_sym
512
+ when :normal_operator_start
513
+ op = (kwargs[:name] || kwargs["name"]).to_sym
514
+ return if op == :delete || op == :change
515
+ when :normal_replace_pending_start, :normal_change_repeat
516
+ return
517
+ end
518
+ end
519
+
520
+ case name.to_sym
521
+ when :normal_register_pending_start
522
+ start_register_pending
523
+ when :normal_operator_start
524
+ start_operator_pending((kwargs[:name] || kwargs["name"]).to_sym)
525
+ when :normal_replace_pending_start
526
+ start_replace_pending
527
+ when :normal_find_pending_start
528
+ start_find_pending((kwargs[:token] || kwargs["token"]).to_s)
529
+ when :normal_find_repeat
530
+ repeat_last_find(reverse: !!(kwargs[:reverse] || kwargs["reverse"]))
531
+ when :normal_change_repeat
532
+ repeat_last_change
533
+ when :normal_macro_record_toggle
534
+ toggle_macro_recording_or_start_pending
535
+ when :normal_macro_play_pending_start
536
+ start_macro_play_pending
537
+ when :normal_mark_pending_start
538
+ start_mark_pending
539
+ when :normal_jump_pending_start
540
+ start_jump_pending(
541
+ linewise: !!(kwargs[:linewise] || kwargs["linewise"]),
542
+ repeat_token: (kwargs[:repeat_token] || kwargs["repeat_token"]).to_s
543
+ )
544
+ else
545
+ raise RuVim::CommandError, "Unknown app action: #{name}"
546
+ end
547
+ end
548
+
366
549
  def handle_normal_key(key)
367
550
  case
368
551
  when handle_normal_key_pre_dispatch(key)
@@ -417,43 +600,7 @@ module RuVim
417
600
  end
418
601
 
419
602
  def handle_normal_direct_token(token)
420
- case token
421
- when "\""
422
- start_register_pending
423
- when "d"
424
- start_operator_pending(:delete)
425
- when "y"
426
- start_operator_pending(:yank)
427
- when "c"
428
- start_operator_pending(:change)
429
- when "r"
430
- start_replace_pending
431
- when "f", "F", "t", "T"
432
- start_find_pending(token)
433
- when ";"
434
- repeat_last_find(reverse: false)
435
- when ","
436
- repeat_last_find(reverse: true)
437
- when "."
438
- repeat_last_change
439
- when "q"
440
- if @editor.macro_recording?
441
- stop_macro_recording
442
- else
443
- start_macro_record_pending
444
- end
445
- when "@"
446
- start_macro_play_pending
447
- when "m"
448
- start_mark_pending
449
- when "'"
450
- start_jump_pending(linewise: true, repeat_token: "'")
451
- when "`"
452
- start_jump_pending(linewise: false, repeat_token: "`")
453
- else
454
- return false
455
- end
456
- true
603
+ false
457
604
  end
458
605
 
459
606
  def resolve_normal_key_sequence
@@ -462,7 +609,7 @@ module RuVim
462
609
  when :pending, :ambiguous
463
610
  if match.status == :ambiguous && match.invocation
464
611
  inv = dup_invocation(match.invocation)
465
- inv.count = @editor.pending_count || 1
612
+ inv.count = @editor.pending_count
466
613
  @pending_ambiguous_invocation = inv
467
614
  else
468
615
  @pending_ambiguous_invocation = nil
@@ -472,9 +619,15 @@ module RuVim
472
619
  when :match
473
620
  clear_pending_key_timeout
474
621
  matched_keys = @pending_keys.dup
475
- repeat_count = @editor.pending_count || 1
622
+ repeat_count = @editor.pending_count
623
+ @pending_keys = []
476
624
  invocation = dup_invocation(match.invocation)
477
625
  invocation.count = repeat_count
626
+ if @editor.rich_mode? && rich_mode_block_command?(invocation.id)
627
+ @editor.pending_count = nil
628
+ @pending_keys = []
629
+ return
630
+ end
478
631
  @dispatcher.dispatch(@editor, invocation)
479
632
  maybe_record_simple_dot_change(invocation, matched_keys, repeat_count)
480
633
  else
@@ -532,6 +685,7 @@ module RuVim
532
685
  @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, key)
533
686
  @editor.current_window.cursor_x += 1
534
687
  maybe_showmatch_after_insert(key)
688
+ maybe_dedent_after_insert(key)
535
689
  end
536
690
  end
537
691
 
@@ -577,6 +731,8 @@ module RuVim
577
731
  when "d"
578
732
  @visual_pending = nil
579
733
  @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_delete"))
734
+ when "="
735
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_indent"))
580
736
  when "\""
581
737
  start_register_pending
582
738
  when "i", "a"
@@ -632,7 +788,7 @@ module RuVim
632
788
 
633
789
  if id
634
790
  clear_pending_key_timeout
635
- count = @editor.pending_count || 1
791
+ count = @editor.pending_count
636
792
  @dispatcher.dispatch(@editor, CommandInvocation.new(id:, count: count))
637
793
  else
638
794
  clear_pending_key_timeout
@@ -744,7 +900,7 @@ module RuVim
744
900
  up: "cursor.up",
745
901
  down: "cursor.down"
746
902
  }.fetch(key)
747
- inv = CommandInvocation.new(id:, count: @editor.pending_count || 1)
903
+ inv = CommandInvocation.new(id:, count: @editor.pending_count)
748
904
  @dispatcher.dispatch(@editor, inv)
749
905
  @editor.pending_count = nil
750
906
  @pending_keys = []
@@ -754,7 +910,7 @@ module RuVim
754
910
  id = (key == :pageup ? "cursor.page_up" : "cursor.page_down")
755
911
  inv = CommandInvocation.new(
756
912
  id: id,
757
- count: @editor.pending_count || 1,
913
+ count: @editor.pending_count,
758
914
  kwargs: { page_lines: current_page_step_lines }
759
915
  )
760
916
  @dispatcher.dispatch(@editor, inv)
@@ -789,6 +945,7 @@ module RuVim
789
945
  when :ctrl_o then "<C-o>"
790
946
  when :ctrl_w then "<C-w>"
791
947
  when :ctrl_l then "<C-l>"
948
+ when :ctrl_c then "<C-c>"
792
949
  when :left then "<Left>"
793
950
  when :right then "<Right>"
794
951
  when :up then "<Up>"
@@ -797,6 +954,10 @@ module RuVim
797
954
  when :end then "<End>"
798
955
  when :pageup then "<PageUp>"
799
956
  when :pagedown then "<PageDown>"
957
+ when :shift_up then "<S-Up>"
958
+ when :shift_down then "<S-Down>"
959
+ when :shift_left then "<S-Left>"
960
+ when :shift_right then "<S-Right>"
800
961
  else nil
801
962
  end
802
963
  end
@@ -812,8 +973,49 @@ module RuVim
812
973
  )
813
974
  end
814
975
 
976
+ # Rich mode: delegates to normal mode key handling but blocks mutating operations.
977
+ RICH_MODE_BLOCKED_COMMANDS = %w[
978
+ mode.insert mode.append mode.append_line_end mode.insert_nonblank
979
+ mode.open_below mode.open_above
980
+ buffer.delete_char buffer.delete_line buffer.delete_motion
981
+ buffer.change_motion buffer.change_line
982
+ buffer.paste_after buffer.paste_before
983
+ buffer.replace_char
984
+ buffer.visual_delete
985
+ ].freeze
986
+
987
+ def handle_hit_enter_key(key)
988
+ token = normalize_key_token(key)
989
+ case token
990
+ when ":"
991
+ @editor.exit_hit_enter_mode
992
+ @editor.enter_command_line_mode(":")
993
+ when "/", "?"
994
+ @editor.exit_hit_enter_mode
995
+ @editor.enter_command_line_mode(token)
996
+ else
997
+ @editor.exit_hit_enter_mode
998
+ end
999
+ end
1000
+
1001
+ def handle_rich_key(key)
1002
+ token = normalize_key_token(key)
1003
+ if token == "\e"
1004
+ RuVim::RichView.close!(@editor)
1005
+ return
1006
+ end
1007
+
1008
+ handle_normal_key(key)
1009
+ end
1010
+
1011
+ def rich_mode_block_command?(command_id)
1012
+ RICH_MODE_BLOCKED_COMMANDS.include?(command_id.to_s)
1013
+ end
1014
+
815
1015
  def handle_ctrl_c
816
1016
  case @editor.mode
1017
+ when :hit_enter
1018
+ @editor.exit_hit_enter_mode
817
1019
  when :insert
818
1020
  finish_insert_change_group
819
1021
  finish_dot_change_capture
@@ -832,6 +1034,18 @@ module RuVim
832
1034
  @jump_pending = nil
833
1035
  clear_pending_key_timeout
834
1036
  @editor.enter_normal_mode
1037
+ when :rich
1038
+ clear_pending_key_timeout
1039
+ @editor.pending_count = nil
1040
+ @pending_keys = []
1041
+ @operator_pending = nil
1042
+ @replace_pending = nil
1043
+ @register_pending = false
1044
+ @mark_pending = false
1045
+ @jump_pending = nil
1046
+ @macro_record_pending = false
1047
+ @macro_play_pending = false
1048
+ RuVim::RichView.close!(@editor)
835
1049
  else
836
1050
  clear_pending_key_timeout
837
1051
  @editor.pending_count = nil
@@ -847,6 +1061,28 @@ module RuVim
847
1061
  end
848
1062
  end
849
1063
 
1064
+ def handle_normal_ctrl_c
1065
+ clear_pending_key_timeout
1066
+ @editor.pending_count = nil
1067
+ @pending_keys = []
1068
+ @operator_pending = nil
1069
+ @replace_pending = nil
1070
+ @register_pending = false
1071
+ @mark_pending = false
1072
+ @jump_pending = nil
1073
+ @macro_record_pending = false
1074
+ @macro_play_pending = false
1075
+ @editor.clear_message
1076
+ end
1077
+
1078
+ def suspend_to_shell
1079
+ @terminal.suspend_for_tstp
1080
+ @screen.invalidate_cache! if @screen.respond_to?(:invalidate_cache!)
1081
+ @needs_redraw = true
1082
+ rescue StandardError => e
1083
+ @editor.echo_error("suspend failed: #{e.message}")
1084
+ end
1085
+
850
1086
  def finish_insert_change_group
851
1087
  @editor.current_buffer.end_change_group
852
1088
  end
@@ -871,7 +1107,7 @@ module RuVim
871
1107
  end
872
1108
 
873
1109
  def start_operator_pending(name)
874
- @operator_pending = { name:, count: (@editor.pending_count || 1) }
1110
+ @operator_pending = { name:, count: @editor.pending_count }
875
1111
  @editor.pending_count = nil
876
1112
  @pending_keys = []
877
1113
  @editor.echo(name == :delete ? "d" : name.to_s)
@@ -946,6 +1182,14 @@ module RuVim
946
1182
  @editor.echo("q")
947
1183
  end
948
1184
 
1185
+ def toggle_macro_recording_or_start_pending
1186
+ if @editor.macro_recording?
1187
+ stop_macro_recording
1188
+ else
1189
+ start_macro_record_pending
1190
+ end
1191
+ end
1192
+
949
1193
  def finish_macro_record_pending(token)
950
1194
  @macro_record_pending = false
951
1195
  if token == "\e"
@@ -993,7 +1237,7 @@ module RuVim
993
1237
  return
994
1238
  end
995
1239
 
996
- count = @editor.pending_count || 1
1240
+ count = @editor.pending_count
997
1241
  @editor.pending_count = nil
998
1242
  play_macro(name, count:)
999
1243
  end
@@ -1015,7 +1259,7 @@ module RuVim
1015
1259
  @last_macro_name = reg
1016
1260
  @macro_play_stack << reg
1017
1261
  @suspend_macro_recording_depth = (@suspend_macro_recording_depth || 0) + 1
1018
- count.times do
1262
+ [count.to_i, 1].max.times do
1019
1263
  keys.each { |k| handle_key(dup_macro_runtime_key(k)) }
1020
1264
  end
1021
1265
  @editor.echo("@#{reg}")
@@ -1086,6 +1330,18 @@ module RuVim
1086
1330
  return
1087
1331
  end
1088
1332
 
1333
+ if op[:name] == :indent && motion == "="
1334
+ inv = CommandInvocation.new(id: "buffer.indent_lines", count: op[:count])
1335
+ @dispatcher.dispatch(@editor, inv)
1336
+ return
1337
+ end
1338
+
1339
+ if op[:name] == :indent
1340
+ inv = CommandInvocation.new(id: "buffer.indent_motion", count: op[:count], kwargs: { motion: motion })
1341
+ @dispatcher.dispatch(@editor, inv)
1342
+ return
1343
+ end
1344
+
1089
1345
  if op[:name] == :change && motion == "c"
1090
1346
  inv = CommandInvocation.new(id: "buffer.change_line", count: op[:count])
1091
1347
  @dispatcher.dispatch(@editor, inv)
@@ -1104,7 +1360,7 @@ module RuVim
1104
1360
  end
1105
1361
 
1106
1362
  def start_replace_pending
1107
- @replace_pending = { count: (@editor.pending_count || 1) }
1363
+ @replace_pending = { count: @editor.pending_count }
1108
1364
  @editor.pending_count = nil
1109
1365
  @pending_keys = []
1110
1366
  @editor.echo("r")
@@ -1145,9 +1401,9 @@ module RuVim
1145
1401
  return if (@dot_replay_depth || 0).positive?
1146
1402
 
1147
1403
  case invocation.id
1148
- when "buffer.delete_char", "buffer.paste_after", "buffer.paste_before"
1404
+ when "buffer.delete_char", "buffer.delete_motion", "buffer.join_lines", "buffer.swapcase_char", "buffer.paste_after", "buffer.paste_before"
1149
1405
  record_last_change_keys(count_prefixed_keys(count, matched_keys))
1150
- when "mode.insert", "mode.append", "mode.append_line_end", "mode.insert_nonblank", "mode.open_below", "mode.open_above"
1406
+ when "mode.insert", "mode.append", "mode.append_line_end", "mode.insert_nonblank", "mode.open_below", "mode.open_above", "buffer.substitute_char", "buffer.change_motion", "buffer.change_line"
1151
1407
  begin_dot_change_capture(count_prefixed_keys(count, matched_keys)) if @editor.mode == :insert
1152
1408
  end
1153
1409
  end
@@ -1192,7 +1448,7 @@ module RuVim
1192
1448
  @find_pending = {
1193
1449
  direction: (token == "f" || token == "t") ? :forward : :backward,
1194
1450
  till: (token == "t" || token == "T"),
1195
- count: (@editor.pending_count || 1)
1451
+ count: @editor.pending_count
1196
1452
  }
1197
1453
  @editor.pending_count = nil
1198
1454
  @pending_keys = []
@@ -1237,7 +1493,7 @@ module RuVim
1237
1493
  else
1238
1494
  last[:direction]
1239
1495
  end
1240
- count = @editor.pending_count || 1
1496
+ count = @editor.pending_count
1241
1497
  @editor.pending_count = nil
1242
1498
  @pending_keys = []
1243
1499
  moved = perform_find_on_line(char: last[:char], direction:, till: last[:till], count:)
@@ -1251,7 +1507,7 @@ module RuVim
1251
1507
  pos = win.cursor_x
1252
1508
  target = nil
1253
1509
 
1254
- count.times do
1510
+ [count.to_i, 1].max.times do
1255
1511
  idx =
1256
1512
  if direction == :forward
1257
1513
  line.index(char, pos + 1)
@@ -1305,6 +1561,66 @@ module RuVim
1305
1561
  @cmdline_history_index = nil
1306
1562
  end
1307
1563
 
1564
+ def load_command_line_history!
1565
+ path = command_line_history_file_path
1566
+ return unless path
1567
+ return unless File.file?(path)
1568
+
1569
+ raw = File.read(path)
1570
+ data = JSON.parse(raw)
1571
+ return unless data.is_a?(Hash)
1572
+
1573
+ loaded = Hash.new { |h, k| h[k] = [] }
1574
+ data.each do |prefix, items|
1575
+ key = prefix.to_s
1576
+ next unless [":", "/", "?"].include?(key)
1577
+ next unless items.is_a?(Array)
1578
+
1579
+ hist = loaded[key]
1580
+ items.each do |item|
1581
+ text = item.to_s
1582
+ next if text.empty?
1583
+
1584
+ hist.delete(text)
1585
+ hist << text
1586
+ end
1587
+ hist.shift while hist.length > 100
1588
+ end
1589
+ @cmdline_history = loaded
1590
+ rescue StandardError => e
1591
+ verbose_log(1, "history load error: #{e.message}")
1592
+ end
1593
+
1594
+ def save_command_line_history!
1595
+ path = command_line_history_file_path
1596
+ return unless path
1597
+
1598
+ payload = {
1599
+ ":" => Array(@cmdline_history[":"]).map(&:to_s).last(100),
1600
+ "/" => Array(@cmdline_history["/"]).map(&:to_s).last(100),
1601
+ "?" => Array(@cmdline_history["?"]).map(&:to_s).last(100)
1602
+ }
1603
+
1604
+ FileUtils.mkdir_p(File.dirname(path))
1605
+ tmp = "#{path}.tmp"
1606
+ File.write(tmp, JSON.pretty_generate(payload) + "\n")
1607
+ File.rename(tmp, path)
1608
+ rescue StandardError => e
1609
+ verbose_log(1, "history save error: #{e.message}")
1610
+ end
1611
+
1612
+ def command_line_history_file_path
1613
+ xdg_state_home = ENV["XDG_STATE_HOME"].to_s
1614
+ if !xdg_state_home.empty?
1615
+ return File.join(xdg_state_home, "ruvim", "history.json")
1616
+ end
1617
+
1618
+ home = ENV["HOME"].to_s
1619
+ return nil if home.empty?
1620
+
1621
+ File.join(home, ".ruvim", "history.json")
1622
+ end
1623
+
1308
1624
  def command_line_history_move(delta)
1309
1625
  cmd = @editor.command_line
1310
1626
  hist = @cmdline_history[cmd.prefix]
@@ -1333,7 +1649,7 @@ module RuVim
1333
1649
  ctx = ex_completion_context(cmd)
1334
1650
  return unless ctx
1335
1651
 
1336
- matches = ex_completion_candidates(ctx)
1652
+ matches = reusable_command_line_completion_matches(cmd, ctx) || ex_completion_candidates(ctx)
1337
1653
  case matches.length
1338
1654
  when 0
1339
1655
  clear_command_line_completion
@@ -1347,6 +1663,29 @@ module RuVim
1347
1663
  update_incsearch_preview_if_needed
1348
1664
  end
1349
1665
 
1666
+ def reusable_command_line_completion_matches(cmd, ctx)
1667
+ state = @cmdline_completion
1668
+ return nil unless state
1669
+ return nil unless state[:prefix] == cmd.prefix
1670
+ return nil unless state[:kind] == ctx[:kind]
1671
+ return nil unless state[:command] == ctx[:command]
1672
+ return nil unless state[:arg_index] == ctx[:arg_index]
1673
+ return nil unless state[:token_start] == ctx[:token_start]
1674
+
1675
+ before_text = cmd.text[0...ctx[:token_start]].to_s
1676
+ after_text = cmd.text[ctx[:token_end]..].to_s
1677
+ return nil unless state[:before_text] == before_text
1678
+ return nil unless state[:after_text] == after_text
1679
+
1680
+ matches = Array(state[:matches]).map(&:to_s)
1681
+ return nil if matches.empty?
1682
+
1683
+ current_token = cmd.text[ctx[:token_start]...ctx[:token_end]].to_s
1684
+ return nil unless current_token.empty? || matches.include?(current_token) || common_prefix(matches).start_with?(current_token) || current_token.start_with?(common_prefix(matches))
1685
+
1686
+ matches
1687
+ end
1688
+
1350
1689
  def clear_command_line_completion
1351
1690
  @cmdline_completion = nil
1352
1691
  end
@@ -1419,13 +1758,56 @@ module RuVim
1419
1758
  def show_command_line_completion_menu(matches, selected:, force:)
1420
1759
  return unless force || @editor.effective_option("wildmenu")
1421
1760
 
1422
- limit = [@editor.effective_option("pumheight").to_i, 1].max
1423
- items = matches.first(limit).each_with_index.map do |m, i|
1761
+ items = matches.each_with_index.map do |m, i|
1424
1762
  idx = i
1425
1763
  idx == selected ? "[#{m}]" : m
1426
1764
  end
1427
- items << "..." if matches.length > limit
1428
- @editor.echo(items.join(" "))
1765
+ @editor.echo(compose_command_line_completion_menu(items))
1766
+ end
1767
+
1768
+ def compose_command_line_completion_menu(items)
1769
+ parts = Array(items).map(&:to_s)
1770
+ return "" if parts.empty?
1771
+
1772
+ width = command_line_completion_menu_width
1773
+ width = [width.to_i, 1].max
1774
+ out = +""
1775
+ shown = 0
1776
+
1777
+ parts.each_with_index do |item, idx|
1778
+ token = shown.zero? ? item : " #{item}"
1779
+ if out.empty? && token.length > width
1780
+ out = token[0, width]
1781
+ shown = 1
1782
+ break
1783
+ end
1784
+ break if out.length + token.length > width
1785
+
1786
+ out << token
1787
+ shown = idx + 1
1788
+ end
1789
+
1790
+ if shown < parts.length
1791
+ ellipsis = (out.empty? ? "..." : " ...")
1792
+ if out.length + ellipsis.length <= width
1793
+ out << ellipsis
1794
+ elsif width >= 3
1795
+ out = out[0, width - 3] + "..."
1796
+ else
1797
+ out = "." * width
1798
+ end
1799
+ end
1800
+
1801
+ out
1802
+ end
1803
+
1804
+ def command_line_completion_menu_width
1805
+ return 80 unless defined?(@terminal) && @terminal && @terminal.respond_to?(:winsize)
1806
+
1807
+ _rows, cols = @terminal.winsize
1808
+ [cols.to_i, 1].max
1809
+ rescue StandardError
1810
+ 80
1429
1811
  end
1430
1812
 
1431
1813
  def common_prefix(strings)
@@ -1473,7 +1855,11 @@ module RuVim
1473
1855
  indent = prev[/\A[ \t]*/].to_s
1474
1856
  if @editor.effective_option("smartindent", window: win, buffer: buf)
1475
1857
  trimmed = prev.rstrip
1476
- if trimmed.end_with?("{", "[", "(")
1858
+ needs_indent = trimmed.end_with?("{", "[", "(")
1859
+ if !needs_indent
1860
+ needs_indent = buf.lang_module.indent_trigger?(trimmed)
1861
+ end
1862
+ if needs_indent
1477
1863
  sw = @editor.effective_option("shiftwidth", window: win, buffer: buf).to_i
1478
1864
  sw = effective_tabstop(win, buf) if sw <= 0
1479
1865
  sw = 2 if sw <= 0
@@ -1495,6 +1881,34 @@ module RuVim
1495
1881
  @editor.echo_temporary("match", duration_seconds: mt * 0.1)
1496
1882
  end
1497
1883
 
1884
+ def maybe_dedent_after_insert(key)
1885
+ return unless @editor.effective_option("smartindent", window: @editor.current_window, buffer: @editor.current_buffer)
1886
+
1887
+ buf = @editor.current_buffer
1888
+ lang_mod = buf.lang_module
1889
+
1890
+ pattern = lang_mod.dedent_trigger(key)
1891
+ return unless pattern
1892
+
1893
+ row = @editor.current_window.cursor_y
1894
+ line = buf.line_at(row)
1895
+ m = line.match(pattern)
1896
+ return unless m
1897
+
1898
+ sw = @editor.effective_option("shiftwidth", buffer: buf).to_i
1899
+ sw = 2 if sw <= 0
1900
+ target_indent = lang_mod.calculate_indent(buf.lines, row, sw)
1901
+ return unless target_indent
1902
+
1903
+ current_indent = m[1].length
1904
+ return if current_indent == target_indent
1905
+
1906
+ stripped = line.to_s.strip
1907
+ buf.delete_span(row, 0, row, current_indent) if current_indent > 0
1908
+ buf.insert_text(row, 0, " " * target_indent) if target_indent > 0
1909
+ @editor.current_window.cursor_x = target_indent + stripped.length
1910
+ end
1911
+
1498
1912
  def clear_expired_transient_message_if_any
1499
1913
  @needs_redraw = true if @editor.clear_expired_transient_message!(now: monotonic_now)
1500
1914
  end
@@ -1906,15 +2320,27 @@ module RuVim
1906
2320
  else
1907
2321
  File.dirname(input)
1908
2322
  end
1909
- base_dir = "." if base_dir == "."
1910
2323
  partial = input.end_with?("/") ? "" : File.basename(input)
1911
- pattern = input.empty? ? "*" : File.join(base_dir, "#{partial}*")
1912
- Dir.glob(pattern, File::FNM_DOTMATCH).sort.filter_map do |p|
2324
+ pattern =
2325
+ if input.empty?
2326
+ "*"
2327
+ elsif base_dir == "."
2328
+ "#{partial}*"
2329
+ else
2330
+ File.join(base_dir, "#{partial}*")
2331
+ end
2332
+ partial_starts_with_dot = partial.start_with?(".")
2333
+ entries = Dir.glob(pattern, File::FNM_DOTMATCH).filter_map do |p|
1913
2334
  next if [".", ".."].include?(File.basename(p))
1914
2335
  next unless p.start_with?(input) || input.empty?
1915
2336
  next if wildignore_path?(p)
1916
2337
  File.directory?(p) ? "#{p}/" : p
1917
2338
  end
2339
+ entries.sort_by do |p|
2340
+ base = File.basename(p.to_s.sub(%r{/\z}, ""))
2341
+ hidden_rank = (!partial_starts_with_dot && base.start_with?(".")) ? 1 : 0
2342
+ [hidden_rank, p]
2343
+ end
1918
2344
  rescue StandardError
1919
2345
  []
1920
2346
  end
@@ -2096,6 +2522,13 @@ module RuVim
2096
2522
  list = Array(paths).compact
2097
2523
  return if list.empty?
2098
2524
 
2525
+ # Remove the bootstrap empty buffer and reset the ID counter
2526
+ # so the first file gets buffer id 1 (Vim-like behavior).
2527
+ evict_bootstrap_buffer!
2528
+
2529
+ # Initialize arglist with all paths
2530
+ @editor.set_arglist(list)
2531
+
2099
2532
  first, *rest = list
2100
2533
  @editor.open_path(first)
2101
2534
  apply_startup_readonly! if @startup_readonly
@@ -2103,20 +2536,38 @@ module RuVim
2103
2536
 
2104
2537
  case @startup_open_layout
2105
2538
  when :horizontal
2539
+ first_win_id = @editor.current_window_id
2106
2540
  rest.each { |p| open_path_in_split!(p, layout: :horizontal) }
2541
+ @editor.focus_window(first_win_id)
2107
2542
  when :vertical
2543
+ first_win_id = @editor.current_window_id
2108
2544
  rest.each { |p| open_path_in_split!(p, layout: :vertical) }
2545
+ @editor.focus_window(first_win_id)
2109
2546
  when :tab
2110
2547
  rest.each { |p| open_path_in_tab!(p) }
2548
+ @editor.tabnext(-(@editor.tabpage_count - 1))
2111
2549
  else
2112
- # No multi-file layout mode yet; ignore extras if called directly.
2550
+ # Load remaining files as buffers (Vim-like behavior).
2551
+ rest.each { |p| @editor.add_buffer_from_file(p) }
2113
2552
  end
2114
2553
  end
2115
2554
 
2555
+ # Remove the bootstrap empty buffer before opening real files,
2556
+ # resetting the buffer ID counter so the first file gets id 1.
2557
+ def evict_bootstrap_buffer!
2558
+ bid = @editor.buffer_ids.find do |id|
2559
+ b = @editor.buffers[id]
2560
+ b.path.nil? && !b.modified? && b.line_count <= 1 && b.kind == :file
2561
+ end
2562
+ return unless bid
2563
+
2564
+ @editor.buffers.delete(bid)
2565
+ @editor.instance_variable_set(:@next_buffer_id, 1)
2566
+ end
2567
+
2116
2568
  def open_path_in_split!(path, layout:)
2117
2569
  @editor.split_current_window(layout:)
2118
- buf = @editor.add_buffer_from_file(path)
2119
- @editor.switch_to_buffer(buf.id)
2570
+ @editor.open_path(path)
2120
2571
  apply_startup_readonly! if @startup_readonly
2121
2572
  apply_startup_nomodifiable! if @startup_nomodifiable
2122
2573
  end
@@ -2127,6 +2578,368 @@ module RuVim
2127
2578
  apply_startup_nomodifiable! if @startup_nomodifiable
2128
2579
  end
2129
2580
 
2581
+ def open_path_with_large_file_support(path)
2582
+ return @editor.open_path_sync(path) unless should_open_path_async?(path)
2583
+ return @editor.open_path_sync(path) unless can_start_async_file_load?
2584
+
2585
+ open_path_asynchronously!(path)
2586
+ end
2587
+
2588
+ def should_open_path_async?(path)
2589
+ p = path.to_s
2590
+ return false if p.empty?
2591
+ return false unless File.file?(p)
2592
+
2593
+ File.size(p) >= large_file_async_threshold_bytes
2594
+ rescue StandardError
2595
+ false
2596
+ end
2597
+
2598
+ def can_start_async_file_load?
2599
+ @async_file_loads.empty?
2600
+ end
2601
+
2602
+ def large_file_async_threshold_bytes
2603
+ raw = ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"]
2604
+ n = raw.to_i if raw
2605
+ return n if n && n.positive?
2606
+
2607
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES
2608
+ end
2609
+
2610
+ def open_path_asynchronously!(path)
2611
+ file_size = File.size(path)
2612
+ buf = @editor.add_empty_buffer(path: path)
2613
+ @editor.switch_to_buffer(buf.id)
2614
+ buf.loading_state = :live
2615
+ buf.modified = false
2616
+
2617
+ ensure_stream_event_queue!
2618
+ io = File.open(path, "rb")
2619
+ state = { path: path, io: io, thread: nil, ended_with_newline: false }
2620
+ staged_prefix_bytes = async_file_staged_prefix_bytes
2621
+ staged_mode = file_size > staged_prefix_bytes
2622
+ if staged_mode
2623
+ prefix = io.read(staged_prefix_bytes) || "".b
2624
+ unless prefix.empty?
2625
+ buf.append_stream_text!(Buffer.decode_text(prefix))
2626
+ state[:ended_with_newline] = prefix.end_with?("\n")
2627
+ end
2628
+ end
2629
+
2630
+ if io.eof?
2631
+ buf.finalize_async_file_load!(ended_with_newline: state[:ended_with_newline])
2632
+ buf.loading_state = :closed
2633
+ io.close unless io.closed?
2634
+ return buf
2635
+ end
2636
+
2637
+ @async_file_loads[buf.id] = state
2638
+ state[:thread] = start_async_file_loader_thread(buf.id, io, bulk_once: staged_mode)
2639
+
2640
+ size_mb = file_size.fdiv(1024 * 1024)
2641
+ if staged_mode
2642
+ @editor.echo(format("\"%s\" loading... (showing first %.0fMB of %.1fMB)", path, staged_prefix_bytes.fdiv(1024 * 1024), size_mb))
2643
+ else
2644
+ @editor.echo(format("\"%s\" loading... (%.1fMB)", path, size_mb))
2645
+ end
2646
+ buf
2647
+ rescue StandardError
2648
+ @async_file_loads.delete(buf.id) if buf
2649
+ raise
2650
+ end
2651
+
2652
+ def async_file_staged_prefix_bytes
2653
+ raw = ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"]
2654
+ n = raw.to_i if raw
2655
+ return n if n && n.positive?
2656
+
2657
+ LARGE_FILE_STAGED_PREFIX_BYTES
2658
+ end
2659
+
2660
+ def start_async_file_loader_thread(buffer_id, io, bulk_once: false)
2661
+ Thread.new do
2662
+ if bulk_once
2663
+ rest = io.read || "".b
2664
+ unless rest.empty?
2665
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: Buffer.decode_text(rest) }
2666
+ notify_signal_wakeup
2667
+ end
2668
+ @stream_event_queue << { type: :file_eof, buffer_id: buffer_id, ended_with_newline: rest.end_with?("\n") }
2669
+ notify_signal_wakeup
2670
+ next
2671
+ end
2672
+
2673
+ ended_with_newline = false
2674
+ pending_text = +""
2675
+ loop do
2676
+ chunk = io.readpartial(ASYNC_FILE_READ_CHUNK_BYTES)
2677
+ next if chunk.nil? || chunk.empty?
2678
+
2679
+ ended_with_newline = chunk.end_with?("\n")
2680
+ pending_text << Buffer.decode_text(chunk)
2681
+ next if pending_text.bytesize < ASYNC_FILE_EVENT_FLUSH_BYTES
2682
+
2683
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: pending_text }
2684
+ pending_text = +""
2685
+ notify_signal_wakeup
2686
+ end
2687
+ rescue EOFError
2688
+ unless pending_text.empty?
2689
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: pending_text }
2690
+ notify_signal_wakeup
2691
+ end
2692
+ @stream_event_queue << { type: :file_eof, buffer_id: buffer_id, ended_with_newline: ended_with_newline }
2693
+ notify_signal_wakeup
2694
+ rescue StandardError => e
2695
+ @stream_event_queue << { type: :file_error, buffer_id: buffer_id, error: e.message.to_s }
2696
+ notify_signal_wakeup
2697
+ ensure
2698
+ begin
2699
+ io.close unless io.closed?
2700
+ rescue StandardError
2701
+ nil
2702
+ end
2703
+ end
2704
+ end
2705
+
2706
+ def prepare_stdin_stream_buffer!
2707
+ buf = @editor.current_buffer
2708
+ if buf.intro_buffer?
2709
+ @editor.materialize_intro_buffer!
2710
+ buf = @editor.current_buffer
2711
+ end
2712
+
2713
+ buf.replace_all_lines!([""])
2714
+ buf.configure_special!(kind: :stream, name: "[stdin]", readonly: true, modifiable: false)
2715
+ buf.modified = false
2716
+ buf.stream_state = :live
2717
+ buf.options["filetype"] = "text"
2718
+ @stream_stop_requested = false
2719
+ ensure_stream_event_queue!
2720
+ @stream_buffer_id = buf.id
2721
+ move_window_to_stream_end!(@editor.current_window, buf)
2722
+ @editor.echo("[stdin] follow")
2723
+ end
2724
+
2725
+ def stdin_stream_stop_command
2726
+ return if stop_stdin_stream!
2727
+
2728
+ handle_normal_ctrl_c
2729
+ end
2730
+
2731
+ def stop_stdin_stream!
2732
+ buf = @editor.buffers[@stream_buffer_id]
2733
+ return false unless buf&.kind == :stream
2734
+ return false unless (buf.stream_state || :live) == :live
2735
+
2736
+ @stream_stop_requested = true
2737
+ io = @stdin_stream_source
2738
+ @stdin_stream_source = nil
2739
+ begin
2740
+ io.close if io && io.respond_to?(:close) && !(io.respond_to?(:closed?) && io.closed?)
2741
+ rescue StandardError
2742
+ nil
2743
+ end
2744
+ if @stream_reader_thread&.alive?
2745
+ @stream_reader_thread.kill
2746
+ @stream_reader_thread.join(0.05)
2747
+ end
2748
+ @stream_reader_thread = nil
2749
+
2750
+ buf.stream_state = :closed
2751
+ @editor.echo("[stdin] closed")
2752
+ notify_signal_wakeup
2753
+ true
2754
+ end
2755
+
2756
+ def start_stdin_stream_reader!
2757
+ return unless @stdin_stream_source
2758
+ ensure_stream_event_queue!
2759
+ return if @stream_reader_thread&.alive?
2760
+
2761
+ @stream_stop_requested = false
2762
+ io = @stdin_stream_source
2763
+ @stream_reader_thread = Thread.new do
2764
+ loop do
2765
+ chunk = io.readpartial(4096)
2766
+ next if chunk.nil? || chunk.empty?
2767
+
2768
+ @stream_event_queue << { type: :data, data: Buffer.decode_text(chunk) }
2769
+ notify_signal_wakeup
2770
+ end
2771
+ rescue EOFError
2772
+ unless @stream_stop_requested
2773
+ @stream_event_queue << { type: :eof }
2774
+ notify_signal_wakeup
2775
+ end
2776
+ rescue IOError => e
2777
+ unless @stream_stop_requested
2778
+ @stream_event_queue << { type: :error, error: e.message.to_s }
2779
+ notify_signal_wakeup
2780
+ end
2781
+ rescue StandardError => e
2782
+ unless @stream_stop_requested
2783
+ @stream_event_queue << { type: :error, error: e.message.to_s }
2784
+ notify_signal_wakeup
2785
+ end
2786
+ end
2787
+ end
2788
+
2789
+ def drain_stream_events!
2790
+ return false unless @stream_event_queue
2791
+
2792
+ changed = false
2793
+ loop do
2794
+ event = @stream_event_queue.pop(true)
2795
+ case event[:type]
2796
+ when :data
2797
+ changed = apply_stream_chunk!(event[:data]) || changed
2798
+ when :eof
2799
+ if (buf = @editor.buffers[@stream_buffer_id])
2800
+ buf.stream_state = :closed
2801
+ end
2802
+ @editor.echo("[stdin] EOF")
2803
+ changed = true
2804
+ when :error
2805
+ next if ignore_stream_shutdown_error?(event[:error])
2806
+ if (buf = @editor.buffers[@stream_buffer_id])
2807
+ buf.stream_state = :error
2808
+ end
2809
+ @editor.echo_error("[stdin] stream error: #{event[:error]}")
2810
+ changed = true
2811
+ when :file_data
2812
+ changed = apply_async_file_chunk!(event[:buffer_id], event[:data]) || changed
2813
+ when :file_eof
2814
+ changed = finish_async_file_load!(event[:buffer_id], ended_with_newline: event[:ended_with_newline]) || changed
2815
+ when :file_error
2816
+ changed = fail_async_file_load!(event[:buffer_id], event[:error]) || changed
2817
+ end
2818
+ end
2819
+ rescue ThreadError
2820
+ changed
2821
+ end
2822
+
2823
+ def apply_stream_chunk!(text)
2824
+ return false if text.to_s.empty?
2825
+
2826
+ buf = @editor.buffers[@stream_buffer_id]
2827
+ return false unless buf
2828
+
2829
+ follow_window_ids = @editor.windows.values.filter_map do |win|
2830
+ next unless win.buffer_id == buf.id
2831
+ next unless stream_window_following_end?(win, buf)
2832
+
2833
+ win.id
2834
+ end
2835
+
2836
+ buf.append_stream_text!(text)
2837
+
2838
+ follow_window_ids.each do |win_id|
2839
+ win = @editor.windows[win_id]
2840
+ move_window_to_stream_end!(win, buf) if win
2841
+ end
2842
+
2843
+ true
2844
+ end
2845
+
2846
+ def apply_async_file_chunk!(buffer_id, text)
2847
+ return false if text.to_s.empty?
2848
+
2849
+ buf = @editor.buffers[buffer_id]
2850
+ return false unless buf
2851
+
2852
+ buf.append_stream_text!(text)
2853
+ true
2854
+ end
2855
+
2856
+ def finish_async_file_load!(buffer_id, ended_with_newline:)
2857
+ @async_file_loads.delete(buffer_id)
2858
+ buf = @editor.buffers[buffer_id]
2859
+ return false unless buf
2860
+
2861
+ buf.finalize_async_file_load!(ended_with_newline: !!ended_with_newline)
2862
+ buf.loading_state = :closed
2863
+ true
2864
+ end
2865
+
2866
+ def fail_async_file_load!(buffer_id, error)
2867
+ state = @async_file_loads.delete(buffer_id)
2868
+ buf = @editor.buffers[buffer_id]
2869
+ if buf
2870
+ buf.loading_state = :error
2871
+ end
2872
+ @editor.echo_error("\"#{(state && state[:path]) || (buf && buf.display_name) || buffer_id}\" load error: #{error}")
2873
+ true
2874
+ end
2875
+
2876
+ def stream_window_following_end?(win, buf)
2877
+ return false unless win
2878
+
2879
+ last_row = buf.line_count - 1
2880
+ win.cursor_y >= last_row
2881
+ end
2882
+
2883
+ def move_window_to_stream_end!(win, buf)
2884
+ return unless win && buf
2885
+
2886
+ last_row = buf.line_count - 1
2887
+ win.cursor_y = last_row
2888
+ win.cursor_x = buf.line_length(last_row)
2889
+ win.clamp_to_buffer(buf)
2890
+ end
2891
+
2892
+ def shutdown_stream_reader!
2893
+ thread = @stream_reader_thread
2894
+ @stream_reader_thread = nil
2895
+ @stream_stop_requested = true
2896
+ return unless thread
2897
+ return unless thread.alive?
2898
+
2899
+ thread.kill
2900
+ thread.join(0.05)
2901
+ rescue StandardError
2902
+ nil
2903
+ end
2904
+
2905
+ def shutdown_async_file_loaders!
2906
+ loaders = @async_file_loads
2907
+ @async_file_loads = {}
2908
+ loaders.each_value do |state|
2909
+ io = state[:io]
2910
+ thread = state[:thread]
2911
+ begin
2912
+ io.close if io && !io.closed?
2913
+ rescue StandardError
2914
+ nil
2915
+ end
2916
+ next unless thread&.alive?
2917
+
2918
+ thread.kill
2919
+ thread.join(0.05)
2920
+ rescue StandardError
2921
+ nil
2922
+ end
2923
+ end
2924
+
2925
+ def shutdown_background_readers!
2926
+ shutdown_stream_reader!
2927
+ shutdown_async_file_loaders!
2928
+ end
2929
+
2930
+ def ignore_stream_shutdown_error?(message)
2931
+ buf = @editor.buffers[@stream_buffer_id]
2932
+ return false unless buf&.kind == :stream
2933
+ return false unless (buf.stream_state || :live) == :closed
2934
+
2935
+ msg = message.to_s.downcase
2936
+ msg.include?("stream closed") || msg.include?("closed in another thread")
2937
+ end
2938
+
2939
+ def ensure_stream_event_queue!
2940
+ @stream_event_queue ||= Queue.new
2941
+ end
2942
+
2130
2943
  def move_cursor_to_line(line_number)
2131
2944
  win = @editor.current_window
2132
2945
  buf = @editor.current_buffer