ruvim 0.2.0 → 0.4.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +96 -0
  4. data/CLAUDE.md +1 -0
  5. data/README.md +15 -1
  6. data/docs/binding.md +39 -0
  7. data/docs/command.md +163 -4
  8. data/docs/config.md +12 -4
  9. data/docs/done.md +21 -0
  10. data/docs/spec.md +214 -18
  11. data/docs/todo.md +1 -5
  12. data/docs/tutorial.md +24 -0
  13. data/docs/vim_diff.md +105 -173
  14. data/lib/ruvim/app.rb +1165 -70
  15. data/lib/ruvim/buffer.rb +47 -1
  16. data/lib/ruvim/cli.rb +18 -3
  17. data/lib/ruvim/clipboard.rb +2 -0
  18. data/lib/ruvim/command_invocation.rb +3 -1
  19. data/lib/ruvim/command_line.rb +2 -0
  20. data/lib/ruvim/command_registry.rb +2 -0
  21. data/lib/ruvim/config_dsl.rb +2 -0
  22. data/lib/ruvim/config_loader.rb +2 -0
  23. data/lib/ruvim/context.rb +2 -0
  24. data/lib/ruvim/dispatcher.rb +143 -13
  25. data/lib/ruvim/display_width.rb +3 -0
  26. data/lib/ruvim/editor.rb +466 -71
  27. data/lib/ruvim/ex_command_registry.rb +2 -0
  28. data/lib/ruvim/file_watcher.rb +243 -0
  29. data/lib/ruvim/git/blame.rb +245 -0
  30. data/lib/ruvim/git/branch.rb +97 -0
  31. data/lib/ruvim/git/commit.rb +102 -0
  32. data/lib/ruvim/git/diff.rb +129 -0
  33. data/lib/ruvim/git/handler.rb +84 -0
  34. data/lib/ruvim/git/log.rb +41 -0
  35. data/lib/ruvim/git/status.rb +103 -0
  36. data/lib/ruvim/global_commands.rb +1066 -105
  37. data/lib/ruvim/highlighter.rb +19 -22
  38. data/lib/ruvim/input.rb +40 -28
  39. data/lib/ruvim/keymap_manager.rb +83 -0
  40. data/lib/ruvim/keyword_chars.rb +2 -0
  41. data/lib/ruvim/lang/base.rb +25 -0
  42. data/lib/ruvim/lang/csv.rb +18 -0
  43. data/lib/ruvim/lang/diff.rb +41 -0
  44. data/lib/ruvim/lang/json.rb +52 -0
  45. data/lib/ruvim/lang/markdown.rb +170 -0
  46. data/lib/ruvim/lang/ruby.rb +236 -0
  47. data/lib/ruvim/lang/scheme.rb +44 -0
  48. data/lib/ruvim/lang/tsv.rb +19 -0
  49. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  50. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  51. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  52. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  53. data/lib/ruvim/rich_view.rb +109 -0
  54. data/lib/ruvim/screen.rb +503 -109
  55. data/lib/ruvim/terminal.rb +18 -1
  56. data/lib/ruvim/text_metrics.rb +2 -0
  57. data/lib/ruvim/version.rb +1 -1
  58. data/lib/ruvim/window.rb +2 -0
  59. data/lib/ruvim.rb +24 -0
  60. data/test/app_completion_test.rb +98 -0
  61. data/test/app_dot_repeat_test.rb +13 -0
  62. data/test/app_motion_test.rb +13 -0
  63. data/test/app_scenario_test.rb +898 -1
  64. data/test/app_startup_test.rb +187 -0
  65. data/test/arglist_test.rb +113 -0
  66. data/test/buffer_test.rb +49 -30
  67. data/test/cli_test.rb +14 -0
  68. data/test/clipboard_test.rb +67 -0
  69. data/test/command_line_test.rb +118 -0
  70. data/test/config_dsl_test.rb +87 -0
  71. data/test/dispatcher_test.rb +322 -0
  72. data/test/display_width_test.rb +41 -0
  73. data/test/editor_register_test.rb +23 -0
  74. data/test/file_watcher_test.rb +197 -0
  75. data/test/follow_test.rb +199 -0
  76. data/test/git_blame_test.rb +713 -0
  77. data/test/highlighter_test.rb +165 -0
  78. data/test/indent_test.rb +287 -0
  79. data/test/input_screen_integration_test.rb +40 -2
  80. data/test/markdown_renderer_test.rb +279 -0
  81. data/test/on_save_hook_test.rb +150 -0
  82. data/test/rich_view_test.rb +734 -0
  83. data/test/screen_test.rb +304 -0
  84. data/test/search_option_test.rb +19 -0
  85. data/test/test_helper.rb +9 -0
  86. metadata +49 -2
data/lib/ruvim/app.rb CHANGED
@@ -1,13 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "file_watcher"
6
+
1
7
  module RuVim
2
8
  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)
9
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES = 64 * 1024 * 1024
10
+ LARGE_FILE_STAGED_PREFIX_BYTES = 8 * 1024 * 1024
11
+ ASYNC_FILE_READ_CHUNK_BYTES = 1 * 1024 * 1024
12
+ ASYNC_FILE_EVENT_FLUSH_BYTES = 4 * 1024 * 1024
13
+
14
+ 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, follow: false, restricted: false, verbose_level: 0, verbose_io: STDERR, startup_time_path: nil, startup_open_layout: nil, startup_open_count: nil)
15
+ startup_paths = Array(paths || path).compact
16
+ @ui_stdin = ui_stdin || stdin
17
+ @stdin_stream_mode = !!stdin_stream_mode
18
+ @stdin_stream_source = @stdin_stream_mode ? stdin : nil
4
19
  @editor = Editor.new
5
- @terminal = Terminal.new(stdin:, stdout:)
6
- @input = Input.new(stdin:)
20
+ @terminal = Terminal.new(stdin: @ui_stdin, stdout:)
21
+ @input = Input.new(@ui_stdin)
7
22
  @screen = Screen.new(terminal: @terminal)
8
23
  @dispatcher = Dispatcher.new
9
24
  @keymaps = KeymapManager.new
10
25
  @signal_r, @signal_w = IO.pipe
26
+ @stream_event_queue = nil
27
+ @stream_reader_thread = nil
28
+ @stream_buffer_id = nil
29
+ @stream_stop_requested = false
30
+ @async_file_loads = {}
31
+ @follow_watchers = {}
11
32
  @cmdline_history = Hash.new { |h, k| h[k] = [] }
12
33
  @cmdline_history_index = nil
13
34
  @cmdline_completion = nil
@@ -24,6 +45,7 @@ module RuVim
24
45
  @startup_quickfix_errorfile = quickfix_errorfile
25
46
  @startup_session_file = session_file
26
47
  @startup_nomodifiable = nomodifiable
48
+ @startup_follow = follow
27
49
  @restricted_mode = restricted
28
50
  @verbose_level = verbose_level.to_i
29
51
  @verbose_io = verbose_io
@@ -33,6 +55,13 @@ module RuVim
33
55
  @startup_open_layout = startup_open_layout
34
56
  @startup_open_count = startup_open_count
35
57
  @editor.restricted_mode = @restricted_mode
58
+ @editor.stdin_stream_stop_handler = method(:stdin_stream_stop_command)
59
+ @editor.open_path_handler = method(:open_path_with_large_file_support)
60
+ @editor.keymap_manager = @keymaps
61
+ @editor.app_action_handler = method(:handle_editor_app_action)
62
+ @editor.git_stream_handler = method(:start_git_stream_command)
63
+ @editor.git_stream_stop_handler = method(:stop_git_stream!)
64
+ load_command_line_history!
36
65
 
37
66
  startup_mark("init.start")
38
67
  register_builtins!
@@ -48,8 +77,10 @@ module RuVim
48
77
  install_signal_handlers
49
78
  startup_mark("signals.installed")
50
79
 
51
- startup_paths = Array(paths || path).compact
52
- if startup_paths.empty?
80
+ if @stdin_stream_mode && startup_paths.empty?
81
+ verbose_log(1, "startup: stdin stream buffer")
82
+ prepare_stdin_stream_buffer!
83
+ elsif startup_paths.empty?
53
84
  verbose_log(1, "startup: intro")
54
85
  @editor.show_intro_buffer_if_applicable!
55
86
  else
@@ -64,12 +95,14 @@ module RuVim
64
95
  verbose_log(1, "startup: run_startup_actions count=#{Array(startup_actions).length}")
65
96
  run_startup_actions!(startup_actions)
66
97
  startup_mark("startup_actions.done")
98
+ start_stdin_stream_reader! if @stream_buffer_id
67
99
  write_startuptime_log!
68
100
  end
69
101
 
70
102
  def run
71
103
  @terminal.with_ui do
72
104
  loop do
105
+ @needs_redraw = true if drain_stream_events!
73
106
  if @needs_redraw
74
107
  @screen.render(@editor)
75
108
  @needs_redraw = false
@@ -89,8 +122,25 @@ module RuVim
89
122
 
90
123
  handle_key(key)
91
124
  @needs_redraw = true
125
+
126
+ # Batch insert-mode keystrokes to avoid per-char rendering during paste
127
+ if @editor.mode == :insert && @input.has_pending_input?
128
+ @paste_batch = true
129
+ begin
130
+ while @editor.mode == :insert && @input.has_pending_input?
131
+ batch_key = @input.read_key(timeout: 0, esc_timeout: 0)
132
+ break unless batch_key
133
+ handle_key(batch_key)
134
+ end
135
+ ensure
136
+ @paste_batch = false
137
+ end
138
+ end
92
139
  end
93
140
  end
141
+ ensure
142
+ shutdown_background_readers!
143
+ save_command_line_history!
94
144
  end
95
145
 
96
146
  def run_startup_actions!(actions, log_prefix: "startup")
@@ -173,6 +223,9 @@ module RuVim
173
223
  register_internal_unless(cmd, "cursor.page_down.half", call: :cursor_page_down_half, desc: "Move half page down")
174
224
  register_internal_unless(cmd, "window.scroll_up.line", call: :window_scroll_up_line, desc: "Scroll window up one line")
175
225
  register_internal_unless(cmd, "window.scroll_down.line", call: :window_scroll_down_line, desc: "Scroll window down one line")
226
+ register_internal_unless(cmd, "window.cursor_line_top", call: :window_cursor_line_top, desc: "Put cursor line at top")
227
+ register_internal_unless(cmd, "window.cursor_line_center", call: :window_cursor_line_center, desc: "Put cursor line at center")
228
+ register_internal_unless(cmd, "window.cursor_line_bottom", call: :window_cursor_line_bottom, desc: "Put cursor line at bottom")
176
229
  register_internal_unless(cmd, "cursor.line_start", call: :cursor_line_start, desc: "Move to column 1")
177
230
  register_internal_unless(cmd, "cursor.line_end", call: :cursor_line_end, desc: "Move to end of line")
178
231
  register_internal_unless(cmd, "cursor.first_nonblank", call: :cursor_first_nonblank, desc: "Move to first nonblank")
@@ -198,10 +251,17 @@ module RuVim
198
251
  register_internal_unless(cmd, "window.focus_right", call: :window_focus_right, desc: "Focus right window")
199
252
  register_internal_unless(cmd, "window.focus_up", call: :window_focus_up, desc: "Focus upper window")
200
253
  register_internal_unless(cmd, "window.focus_down", call: :window_focus_down, desc: "Focus lower window")
254
+ register_internal_unless(cmd, "window.focus_or_split_left", call: :window_focus_or_split_left, desc: "Focus left window or split")
255
+ register_internal_unless(cmd, "window.focus_or_split_right", call: :window_focus_or_split_right, desc: "Focus right window or split")
256
+ register_internal_unless(cmd, "window.focus_or_split_up", call: :window_focus_or_split_up, desc: "Focus upper window or split")
257
+ register_internal_unless(cmd, "window.focus_or_split_down", call: :window_focus_or_split_down, desc: "Focus lower window or split")
201
258
  register_internal_unless(cmd, "mode.command_line", call: :enter_command_line_mode, desc: "Enter command-line mode")
202
259
  register_internal_unless(cmd, "mode.search_forward", call: :enter_search_forward_mode, desc: "Enter / search")
203
260
  register_internal_unless(cmd, "mode.search_backward", call: :enter_search_backward_mode, desc: "Enter ? search")
204
261
  register_internal_unless(cmd, "buffer.delete_char", call: :delete_char, desc: "Delete char under cursor")
262
+ register_internal_unless(cmd, "buffer.substitute_char", call: :substitute_char, desc: "Substitute char(s)")
263
+ register_internal_unless(cmd, "buffer.swapcase_char", call: :swapcase_char, desc: "Swap case under cursor")
264
+ register_internal_unless(cmd, "buffer.join_lines", call: :join_lines, desc: "Join lines")
205
265
  register_internal_unless(cmd, "buffer.delete_line", call: :delete_line, desc: "Delete current line")
206
266
  register_internal_unless(cmd, "buffer.delete_motion", call: :delete_motion, desc: "Delete by motion")
207
267
  register_internal_unless(cmd, "buffer.change_motion", call: :change_motion, desc: "Change by motion")
@@ -231,10 +291,39 @@ module RuVim
231
291
  register_internal_unless(cmd, "buffer.replace_char", call: :replace_char, desc: "Replace single char")
232
292
  register_internal_unless(cmd, "file.goto_under_cursor", call: :file_goto_under_cursor, desc: "Open file under cursor")
233
293
  register_internal_unless(cmd, "ui.clear_message", call: :clear_message, desc: "Clear message")
294
+ 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")
295
+ register_internal_unless(cmd, "normal.operator_delete_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :delete) }, desc: "Start delete operator")
296
+ register_internal_unless(cmd, "normal.operator_yank_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :yank) }, desc: "Start yank operator")
297
+ register_internal_unless(cmd, "normal.operator_change_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :change) }, desc: "Start change operator")
298
+ register_internal_unless(cmd, "normal.operator_indent_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :indent) }, desc: "Start indent operator")
299
+ register_internal_unless(cmd, "buffer.indent_lines", call: :indent_lines, desc: "Auto-indent lines")
300
+ register_internal_unless(cmd, "buffer.indent_motion", call: :indent_motion, desc: "Auto-indent motion range")
301
+ register_internal_unless(cmd, "buffer.visual_indent", call: :visual_indent, desc: "Auto-indent visual selection")
302
+ register_internal_unless(cmd, "normal.replace_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_replace_pending_start) }, desc: "Start replace-char pending")
303
+ 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")
304
+ 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")
305
+ 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")
306
+ 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")
307
+ 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")
308
+ 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")
309
+ register_internal_unless(cmd, "normal.change_repeat", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_change_repeat) }, desc: "Repeat last change")
310
+ register_internal_unless(cmd, "normal.macro_record_toggle", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_macro_record_toggle) }, desc: "Start/stop macro recording")
311
+ 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")
312
+ register_internal_unless(cmd, "normal.mark_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_mark_pending_start) }, desc: "Start mark set pending")
313
+ 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")
314
+ 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")
315
+ register_internal_unless(
316
+ cmd,
317
+ "stdin.stream_stop",
318
+ call: ->(ctx, **) { ctx.editor.stdin_stream_stop_or_cancel! },
319
+ desc: "Stop stdin follow stream (or cancel pending state)"
320
+ )
234
321
 
235
322
  register_ex_unless(ex, "w", call: :file_write, aliases: %w[write], desc: "Write current buffer", nargs: :maybe_one, bang: true)
236
323
  register_ex_unless(ex, "q", call: :app_quit, aliases: %w[quit], desc: "Quit", nargs: 0, bang: true)
324
+ register_ex_unless(ex, "qa", call: :app_quit_all, aliases: %w[qall], desc: "Quit all", nargs: 0, bang: true)
237
325
  register_ex_unless(ex, "wq", call: :file_write_quit, desc: "Write and quit", nargs: :maybe_one, bang: true)
326
+ 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
327
  register_ex_unless(ex, "e", call: :file_edit, aliases: %w[edit], desc: "Edit file / reload", nargs: :maybe_one, bang: true)
239
328
  register_ex_unless(ex, "help", call: :ex_help, desc: "Show help / topics", nargs: :any)
240
329
  register_ex_unless(ex, "command", call: :ex_define_command, desc: "Define user command", nargs: :any, bang: true)
@@ -244,7 +333,13 @@ module RuVim
244
333
  register_ex_unless(ex, "bprev", call: :buffer_prev, aliases: %w[bp], desc: "Previous buffer", nargs: 0, bang: true)
245
334
  register_ex_unless(ex, "buffer", call: :buffer_switch, aliases: %w[b], desc: "Switch buffer", nargs: 1, bang: true)
246
335
  register_ex_unless(ex, "bdelete", call: :buffer_delete, aliases: %w[bd], desc: "Delete buffer", nargs: :maybe_one, bang: true)
336
+ register_ex_unless(ex, "args", call: :arglist_show, desc: "Show argument list", nargs: 0)
337
+ register_ex_unless(ex, "next", call: :arglist_next, desc: "Next argument", nargs: 0)
338
+ register_ex_unless(ex, "prev", call: :arglist_prev, desc: "Previous argument", nargs: 0)
339
+ register_ex_unless(ex, "first", call: :arglist_first, desc: "First argument", nargs: 0)
340
+ register_ex_unless(ex, "last", call: :arglist_last, desc: "Last argument", nargs: 0)
247
341
  register_ex_unless(ex, "commands", call: :ex_commands, desc: "List Ex commands", nargs: 0)
342
+ register_ex_unless(ex, "bindings", call: :ex_bindings, desc: "List active key bindings", nargs: :any)
248
343
  register_ex_unless(ex, "set", call: :ex_set, desc: "Set options", nargs: :any)
249
344
  register_ex_unless(ex, "setlocal", call: :ex_setlocal, desc: "Set window/buffer local option", nargs: :any)
250
345
  register_ex_unless(ex, "setglobal", call: :ex_setglobal, desc: "Set global option", nargs: :any)
@@ -253,6 +348,7 @@ module RuVim
253
348
  register_ex_unless(ex, "tabnew", call: :tab_new, desc: "New tab", nargs: :maybe_one)
254
349
  register_ex_unless(ex, "tabnext", call: :tab_next, aliases: %w[tabn], desc: "Next tab", nargs: 0)
255
350
  register_ex_unless(ex, "tabprev", call: :tab_prev, aliases: %w[tabp], desc: "Prev tab", nargs: 0)
351
+ register_ex_unless(ex, "tabs", call: :tab_list, desc: "List tabs", nargs: 0)
256
352
  register_ex_unless(ex, "vimgrep", call: :ex_vimgrep, desc: "Populate quickfix from regex (minimal)", nargs: :any)
257
353
  register_ex_unless(ex, "lvimgrep", call: :ex_lvimgrep, desc: "Populate location list from regex (minimal)", nargs: :any)
258
354
  register_ex_unless(ex, "copen", call: :ex_copen, desc: "Open quickfix list", nargs: 0)
@@ -263,6 +359,32 @@ module RuVim
263
359
  register_ex_unless(ex, "lclose", call: :ex_lclose, desc: "Close location list window", nargs: 0)
264
360
  register_ex_unless(ex, "lnext", call: :ex_lnext, aliases: %w[ln], desc: "Next location item", nargs: 0)
265
361
  register_ex_unless(ex, "lprev", call: :ex_lprev, aliases: %w[lp], desc: "Prev location item", nargs: 0)
362
+ register_ex_unless(ex, "grep", call: :ex_grep, desc: "Search with external grep", nargs: :any)
363
+ register_ex_unless(ex, "lgrep", call: :ex_lgrep, desc: "Search with external grep (location list)", nargs: :any)
364
+ register_ex_unless(ex, "d", call: :ex_delete_lines, aliases: %w[delete], desc: "Delete lines", nargs: :any)
365
+ register_ex_unless(ex, "y", call: :ex_yank_lines, aliases: %w[yank], desc: "Yank lines", nargs: :any)
366
+ register_ex_unless(ex, "rich", call: :ex_rich, desc: "Open/close Rich View", nargs: :maybe_one)
367
+ register_ex_unless(ex, "follow", call: ->(ctx, **) { ctx.editor.invoke_app_action(:follow_toggle) }, desc: "Toggle file follow mode", nargs: 0)
368
+ register_ex_unless(ex, "nohlsearch", call: ->(ctx, **) { ctx.editor.suppress_hlsearch! }, aliases: %w[noh nohl nohlsearc nohlsear nohlsea nohlse nohls], desc: "Temporarily clear search highlight", nargs: 0)
369
+ register_ex_unless(ex, "filter", call: :ex_filter, desc: "Filter lines matching search pattern", nargs: :any)
370
+ register_internal_unless(cmd, "search.filter", call: :search_filter, desc: "Filter lines matching search pattern")
371
+ register_internal_unless(cmd, "rich.toggle", call: :rich_toggle, desc: "Toggle Rich View")
372
+ register_internal_unless(cmd, "rich.close_buffer", call: :rich_view_close_buffer, desc: "Close rich view buffer")
373
+ register_internal_unless(cmd, "quickfix.next", call: :ex_cnext, desc: "Next quickfix item")
374
+ register_internal_unless(cmd, "quickfix.prev", call: :ex_cprev, desc: "Prev quickfix item")
375
+ register_internal_unless(cmd, "quickfix.open", call: :ex_copen, desc: "Open quickfix list")
376
+
377
+ register_internal_unless(cmd, "git.blame", call: :git_blame, desc: "Open git blame buffer")
378
+ register_internal_unless(cmd, "git.blame.prev", call: :git_blame_prev, desc: "Blame at parent commit")
379
+ register_internal_unless(cmd, "git.blame.back", call: :git_blame_back, desc: "Restore previous blame")
380
+ register_internal_unless(cmd, "git.blame.commit", call: :git_blame_commit, desc: "Show commit details")
381
+ register_internal_unless(cmd, "git.command_mode", call: :enter_git_command_mode, desc: "Enter Git command-line mode")
382
+ register_internal_unless(cmd, "git.close_buffer", call: :git_close_buffer, desc: "Close git buffer")
383
+ register_internal_unless(cmd, "git.status.open_file", call: :git_status_open_file, desc: "Open file from git status")
384
+ register_internal_unless(cmd, "git.diff.open_file", call: :git_diff_open_file, desc: "Open file from git diff")
385
+ register_internal_unless(cmd, "git.branch.checkout", call: :git_branch_checkout, desc: "Checkout branch under cursor")
386
+ register_internal_unless(cmd, "git.commit.execute", call: :git_commit_execute, desc: "Execute git commit")
387
+ register_ex_unless(ex, "git", call: :ex_git, desc: "Git subcommand dispatcher", nargs: :any)
266
388
  end
267
389
 
268
390
  def bind_default_keys!
@@ -297,10 +419,40 @@ module RuVim
297
419
  @keymaps.bind(:normal, ["<C-w>", "j"], "window.focus_down")
298
420
  @keymaps.bind(:normal, ["<C-w>", "k"], "window.focus_up")
299
421
  @keymaps.bind(:normal, ["<C-w>", "l"], "window.focus_right")
422
+ @keymaps.bind(:normal, ["<S-Left>"], "window.focus_or_split_left")
423
+ @keymaps.bind(:normal, ["<S-Right>"], "window.focus_or_split_right")
424
+ @keymaps.bind(:normal, ["<S-Up>"], "window.focus_or_split_up")
425
+ @keymaps.bind(:normal, ["<S-Down>"], "window.focus_or_split_down")
300
426
  @keymaps.bind(:normal, ":", "mode.command_line")
301
427
  @keymaps.bind(:normal, "/", "mode.search_forward")
302
428
  @keymaps.bind(:normal, "?", "mode.search_backward")
303
429
  @keymaps.bind(:normal, "x", "buffer.delete_char")
430
+ @keymaps.bind(:normal, "X", "buffer.delete_motion", kwargs: { motion: "h" })
431
+ @keymaps.bind(:normal, "s", "buffer.substitute_char")
432
+ @keymaps.bind(:normal, "D", "buffer.delete_motion", kwargs: { motion: "$" })
433
+ @keymaps.bind(:normal, "C", "buffer.change_motion", kwargs: { motion: "$" })
434
+ @keymaps.bind(:normal, "S", "buffer.change_line")
435
+ @keymaps.bind(:normal, "Y", "buffer.yank_line")
436
+ @keymaps.bind(:normal, "J", "buffer.join_lines")
437
+ @keymaps.bind(:normal, "~", "buffer.swapcase_char")
438
+ @keymaps.bind(:normal, "\"", "normal.register_pending_start")
439
+ @keymaps.bind(:normal, "d", "normal.operator_delete_start")
440
+ @keymaps.bind(:normal, "y", "normal.operator_yank_start")
441
+ @keymaps.bind(:normal, "c", "normal.operator_change_start")
442
+ @keymaps.bind(:normal, "=", "normal.operator_indent_start")
443
+ @keymaps.bind(:normal, "r", "normal.replace_pending_start")
444
+ @keymaps.bind(:normal, "f", "normal.find_char_forward_start")
445
+ @keymaps.bind(:normal, "F", "normal.find_char_backward_start")
446
+ @keymaps.bind(:normal, "t", "normal.find_till_forward_start")
447
+ @keymaps.bind(:normal, "T", "normal.find_till_backward_start")
448
+ @keymaps.bind(:normal, ";", "normal.find_repeat")
449
+ @keymaps.bind(:normal, ",", "normal.find_repeat_reverse")
450
+ @keymaps.bind(:normal, ".", "normal.change_repeat")
451
+ @keymaps.bind(:normal, "q", "normal.macro_record_toggle")
452
+ @keymaps.bind(:normal, "@", "normal.macro_play_pending_start")
453
+ @keymaps.bind(:normal, "m", "normal.mark_pending_start")
454
+ @keymaps.bind(:normal, "'", "normal.jump_mark_linewise_pending_start")
455
+ @keymaps.bind(:normal, "`", "normal.jump_mark_exact_pending_start")
304
456
  @keymaps.bind(:normal, "p", "buffer.paste_after")
305
457
  @keymaps.bind(:normal, "P", "buffer.paste_before")
306
458
  @keymaps.bind(:normal, "u", "buffer.undo")
@@ -313,6 +465,10 @@ module RuVim
313
465
  @keymaps.bind(:normal, ["<C-b>"], "cursor.page_up.default")
314
466
  @keymaps.bind(:normal, ["<C-e>"], "window.scroll_down.line")
315
467
  @keymaps.bind(:normal, ["<C-y>"], "window.scroll_up.line")
468
+ @keymaps.bind(:normal, "zt", "window.cursor_line_top")
469
+ @keymaps.bind(:normal, "zz", "window.cursor_line_center")
470
+ @keymaps.bind(:normal, "zb", "window.cursor_line_bottom")
471
+ @keymaps.bind(:normal, ["<C-c>"], "stdin.stream_stop")
316
472
  @keymaps.bind(:normal, "n", "search.next")
317
473
  @keymaps.bind(:normal, "N", "search.prev")
318
474
  @keymaps.bind(:normal, "*", "search.word_forward")
@@ -320,6 +476,12 @@ module RuVim
320
476
  @keymaps.bind(:normal, "g*", "search.word_forward_partial")
321
477
  @keymaps.bind(:normal, "g#", "search.word_backward_partial")
322
478
  @keymaps.bind(:normal, "gf", "file.goto_under_cursor")
479
+ @keymaps.bind(:normal, "gr", "rich.toggle")
480
+ @keymaps.bind(:normal, "g/", "search.filter")
481
+ @keymaps.bind(:normal, ["<C-g>"], "git.command_mode")
482
+ @keymaps.bind(:normal, "Q", "quickfix.open")
483
+ @keymaps.bind(:normal, ["]", "q"], "quickfix.next")
484
+ @keymaps.bind(:normal, ["[", "q"], "quickfix.prev")
323
485
  @keymaps.bind(:normal, ["<PageUp>"], "cursor.page_up.default")
324
486
  @keymaps.bind(:normal, ["<PageDown>"], "cursor.page_down.default")
325
487
  @keymaps.bind(:normal, "\e", "ui.clear_message")
@@ -330,7 +492,12 @@ module RuVim
330
492
  clear_stale_message_before_key(key)
331
493
  @skip_record_for_current_key = false
332
494
  append_dot_change_capture_key(key)
333
- if key == :ctrl_c
495
+ if key == :ctrl_z
496
+ suspend_to_shell
497
+ track_mode_transition(mode_before)
498
+ return
499
+ end
500
+ if key == :ctrl_c && @editor.mode != :normal
334
501
  handle_ctrl_c
335
502
  track_mode_transition(mode_before)
336
503
  record_macro_key_if_needed(key)
@@ -338,23 +505,30 @@ module RuVim
338
505
  end
339
506
 
340
507
  case @editor.mode
508
+ when :hit_enter
509
+ handle_hit_enter_key(key)
341
510
  when :insert
342
511
  handle_insert_key(key)
343
512
  when :command_line
344
513
  handle_command_line_key(key)
345
514
  when :visual_char, :visual_line, :visual_block
346
515
  handle_visual_key(key)
516
+ when :rich
517
+ handle_rich_key(key)
347
518
  else
348
519
  handle_normal_key(key)
349
520
  end
350
521
  track_mode_transition(mode_before)
351
522
  load_current_ftplugin!
352
523
  record_macro_key_if_needed(key)
524
+ rescue RuVim::CommandError => e
525
+ @editor.echo_error(e.message)
353
526
  end
354
527
 
355
528
  def clear_stale_message_before_key(key)
356
529
  return if @editor.message.to_s.empty?
357
530
  return if @editor.command_line_active?
531
+ return if @editor.hit_enter_active?
358
532
 
359
533
  # Keep the error visible while the user is still dismissing/cancelling;
360
534
  # otherwise, the next operation replaces the command-line area naturally.
@@ -363,6 +537,48 @@ module RuVim
363
537
  @editor.clear_message
364
538
  end
365
539
 
540
+ def handle_editor_app_action(name, **kwargs)
541
+ if @editor.rich_mode?
542
+ case name.to_sym
543
+ when :normal_operator_start
544
+ op = (kwargs[:name] || kwargs["name"]).to_sym
545
+ return if op == :delete || op == :change
546
+ when :normal_replace_pending_start, :normal_change_repeat
547
+ return
548
+ end
549
+ end
550
+
551
+ case name.to_sym
552
+ when :normal_register_pending_start
553
+ start_register_pending
554
+ when :normal_operator_start
555
+ start_operator_pending((kwargs[:name] || kwargs["name"]).to_sym)
556
+ when :normal_replace_pending_start
557
+ start_replace_pending
558
+ when :normal_find_pending_start
559
+ start_find_pending((kwargs[:token] || kwargs["token"]).to_s)
560
+ when :normal_find_repeat
561
+ repeat_last_find(reverse: !!(kwargs[:reverse] || kwargs["reverse"]))
562
+ when :normal_change_repeat
563
+ repeat_last_change
564
+ when :normal_macro_record_toggle
565
+ toggle_macro_recording_or_start_pending
566
+ when :normal_macro_play_pending_start
567
+ start_macro_play_pending
568
+ when :normal_mark_pending_start
569
+ start_mark_pending
570
+ when :normal_jump_pending_start
571
+ start_jump_pending(
572
+ linewise: !!(kwargs[:linewise] || kwargs["linewise"]),
573
+ repeat_token: (kwargs[:repeat_token] || kwargs["repeat_token"]).to_s
574
+ )
575
+ when :follow_toggle
576
+ ex_follow_toggle
577
+ else
578
+ raise RuVim::CommandError, "Unknown app action: #{name}"
579
+ end
580
+ end
581
+
366
582
  def handle_normal_key(key)
367
583
  case
368
584
  when handle_normal_key_pre_dispatch(key)
@@ -417,43 +633,7 @@ module RuVim
417
633
  end
418
634
 
419
635
  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
636
+ false
457
637
  end
458
638
 
459
639
  def resolve_normal_key_sequence
@@ -462,7 +642,7 @@ module RuVim
462
642
  when :pending, :ambiguous
463
643
  if match.status == :ambiguous && match.invocation
464
644
  inv = dup_invocation(match.invocation)
465
- inv.count = @editor.pending_count || 1
645
+ inv.count = @editor.pending_count
466
646
  @pending_ambiguous_invocation = inv
467
647
  else
468
648
  @pending_ambiguous_invocation = nil
@@ -472,9 +652,15 @@ module RuVim
472
652
  when :match
473
653
  clear_pending_key_timeout
474
654
  matched_keys = @pending_keys.dup
475
- repeat_count = @editor.pending_count || 1
655
+ repeat_count = @editor.pending_count
656
+ @pending_keys = []
476
657
  invocation = dup_invocation(match.invocation)
477
658
  invocation.count = repeat_count
659
+ if @editor.rich_mode? && rich_mode_block_command?(invocation.id)
660
+ @editor.pending_count = nil
661
+ @pending_keys = []
662
+ return
663
+ end
478
664
  @dispatcher.dispatch(@editor, invocation)
479
665
  maybe_record_simple_dot_change(invocation, matched_keys, repeat_count)
480
666
  else
@@ -532,6 +718,7 @@ module RuVim
532
718
  @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, key)
533
719
  @editor.current_window.cursor_x += 1
534
720
  maybe_showmatch_after_insert(key)
721
+ maybe_dedent_after_insert(key)
535
722
  end
536
723
  end
537
724
 
@@ -577,6 +764,8 @@ module RuVim
577
764
  when "d"
578
765
  @visual_pending = nil
579
766
  @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_delete"))
767
+ when "="
768
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_indent"))
580
769
  when "\""
581
770
  start_register_pending
582
771
  when "i", "a"
@@ -632,7 +821,7 @@ module RuVim
632
821
 
633
822
  if id
634
823
  clear_pending_key_timeout
635
- count = @editor.pending_count || 1
824
+ count = @editor.pending_count
636
825
  @dispatcher.dispatch(@editor, CommandInvocation.new(id:, count: count))
637
826
  else
638
827
  clear_pending_key_timeout
@@ -688,6 +877,10 @@ module RuVim
688
877
 
689
878
  def handle_list_window_enter
690
879
  buffer = @editor.current_buffer
880
+ return handle_filter_buffer_enter if buffer.kind == :filter
881
+ return handle_git_status_enter if buffer.kind == :git_status
882
+ return handle_git_diff_enter if buffer.kind == :git_diff || buffer.kind == :git_log
883
+ return handle_git_branch_enter if buffer.kind == :git_branch
691
884
  return false unless buffer.kind == :quickfix || buffer.kind == :location_list
692
885
 
693
886
  item_index = @editor.current_window.cursor_y - 2
@@ -729,6 +922,47 @@ module RuVim
729
922
  true
730
923
  end
731
924
 
925
+ def handle_filter_buffer_enter
926
+ buffer = @editor.current_buffer
927
+ origins = buffer.options["filter_origins"]
928
+ return false unless origins
929
+
930
+ row = @editor.current_window.cursor_y
931
+ origin = origins[row]
932
+ unless origin
933
+ @editor.echo_error("No filter item on this line")
934
+ return true
935
+ end
936
+
937
+ target_buffer_id = origin[:buffer_id]
938
+ target_row = origin[:row]
939
+ filter_buf_id = buffer.id
940
+
941
+ @editor.delete_buffer(filter_buf_id)
942
+ target_buf = @editor.buffers[target_buffer_id]
943
+ if target_buf
944
+ @editor.switch_to_buffer(target_buffer_id) unless @editor.current_buffer.id == target_buffer_id
945
+ @editor.current_window.cursor_y = [target_row, target_buf.lines.length - 1].min
946
+ @editor.current_window.cursor_x = 0
947
+ end
948
+ true
949
+ end
950
+
951
+ def handle_git_status_enter
952
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.status.open_file"))
953
+ true
954
+ end
955
+
956
+ def handle_git_diff_enter
957
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.diff.open_file"))
958
+ true
959
+ end
960
+
961
+ def handle_git_branch_enter
962
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.branch.checkout"))
963
+ true
964
+ end
965
+
732
966
  def arrow_key?(key)
733
967
  %i[left right up down].include?(key)
734
968
  end
@@ -744,7 +978,7 @@ module RuVim
744
978
  up: "cursor.up",
745
979
  down: "cursor.down"
746
980
  }.fetch(key)
747
- inv = CommandInvocation.new(id:, count: @editor.pending_count || 1)
981
+ inv = CommandInvocation.new(id:, count: @editor.pending_count)
748
982
  @dispatcher.dispatch(@editor, inv)
749
983
  @editor.pending_count = nil
750
984
  @pending_keys = []
@@ -754,7 +988,7 @@ module RuVim
754
988
  id = (key == :pageup ? "cursor.page_up" : "cursor.page_down")
755
989
  inv = CommandInvocation.new(
756
990
  id: id,
757
- count: @editor.pending_count || 1,
991
+ count: @editor.pending_count,
758
992
  kwargs: { page_lines: current_page_step_lines }
759
993
  )
760
994
  @dispatcher.dispatch(@editor, inv)
@@ -789,6 +1023,8 @@ module RuVim
789
1023
  when :ctrl_o then "<C-o>"
790
1024
  when :ctrl_w then "<C-w>"
791
1025
  when :ctrl_l then "<C-l>"
1026
+ when :ctrl_c then "<C-c>"
1027
+ when :ctrl_g then "<C-g>"
792
1028
  when :left then "<Left>"
793
1029
  when :right then "<Right>"
794
1030
  when :up then "<Up>"
@@ -797,6 +1033,10 @@ module RuVim
797
1033
  when :end then "<End>"
798
1034
  when :pageup then "<PageUp>"
799
1035
  when :pagedown then "<PageDown>"
1036
+ when :shift_up then "<S-Up>"
1037
+ when :shift_down then "<S-Down>"
1038
+ when :shift_left then "<S-Left>"
1039
+ when :shift_right then "<S-Right>"
800
1040
  else nil
801
1041
  end
802
1042
  end
@@ -812,8 +1052,49 @@ module RuVim
812
1052
  )
813
1053
  end
814
1054
 
1055
+ # Rich mode: delegates to normal mode key handling but blocks mutating operations.
1056
+ RICH_MODE_BLOCKED_COMMANDS = %w[
1057
+ mode.insert mode.append mode.append_line_end mode.insert_nonblank
1058
+ mode.open_below mode.open_above
1059
+ buffer.delete_char buffer.delete_line buffer.delete_motion
1060
+ buffer.change_motion buffer.change_line
1061
+ buffer.paste_after buffer.paste_before
1062
+ buffer.replace_char
1063
+ buffer.visual_delete
1064
+ ].freeze
1065
+
1066
+ def handle_hit_enter_key(key)
1067
+ token = normalize_key_token(key)
1068
+ case token
1069
+ when ":"
1070
+ @editor.exit_hit_enter_mode
1071
+ @editor.enter_command_line_mode(":")
1072
+ when "/", "?"
1073
+ @editor.exit_hit_enter_mode
1074
+ @editor.enter_command_line_mode(token)
1075
+ else
1076
+ @editor.exit_hit_enter_mode
1077
+ end
1078
+ end
1079
+
1080
+ def handle_rich_key(key)
1081
+ token = normalize_key_token(key)
1082
+ if token == "\e"
1083
+ RuVim::RichView.close!(@editor)
1084
+ return
1085
+ end
1086
+
1087
+ handle_normal_key(key)
1088
+ end
1089
+
1090
+ def rich_mode_block_command?(command_id)
1091
+ RICH_MODE_BLOCKED_COMMANDS.include?(command_id.to_s)
1092
+ end
1093
+
815
1094
  def handle_ctrl_c
816
1095
  case @editor.mode
1096
+ when :hit_enter
1097
+ @editor.exit_hit_enter_mode
817
1098
  when :insert
818
1099
  finish_insert_change_group
819
1100
  finish_dot_change_capture
@@ -832,6 +1113,18 @@ module RuVim
832
1113
  @jump_pending = nil
833
1114
  clear_pending_key_timeout
834
1115
  @editor.enter_normal_mode
1116
+ when :rich
1117
+ clear_pending_key_timeout
1118
+ @editor.pending_count = nil
1119
+ @pending_keys = []
1120
+ @operator_pending = nil
1121
+ @replace_pending = nil
1122
+ @register_pending = false
1123
+ @mark_pending = false
1124
+ @jump_pending = nil
1125
+ @macro_record_pending = false
1126
+ @macro_play_pending = false
1127
+ RuVim::RichView.close!(@editor)
835
1128
  else
836
1129
  clear_pending_key_timeout
837
1130
  @editor.pending_count = nil
@@ -847,6 +1140,33 @@ module RuVim
847
1140
  end
848
1141
  end
849
1142
 
1143
+ def handle_normal_ctrl_c
1144
+ clear_pending_key_timeout
1145
+ @editor.pending_count = nil
1146
+ @pending_keys = []
1147
+ @operator_pending = nil
1148
+ @replace_pending = nil
1149
+ @register_pending = false
1150
+ @mark_pending = false
1151
+ @jump_pending = nil
1152
+ @macro_record_pending = false
1153
+ @macro_play_pending = false
1154
+ buf = @editor.current_buffer
1155
+ if buf && @follow_watchers[buf.id]
1156
+ stop_follow!(buf)
1157
+ else
1158
+ @editor.clear_message
1159
+ end
1160
+ end
1161
+
1162
+ def suspend_to_shell
1163
+ @terminal.suspend_for_tstp
1164
+ @screen.invalidate_cache! if @screen.respond_to?(:invalidate_cache!)
1165
+ @needs_redraw = true
1166
+ rescue StandardError => e
1167
+ @editor.echo_error("suspend failed: #{e.message}")
1168
+ end
1169
+
850
1170
  def finish_insert_change_group
851
1171
  @editor.current_buffer.end_change_group
852
1172
  end
@@ -871,7 +1191,7 @@ module RuVim
871
1191
  end
872
1192
 
873
1193
  def start_operator_pending(name)
874
- @operator_pending = { name:, count: (@editor.pending_count || 1) }
1194
+ @operator_pending = { name:, count: @editor.pending_count }
875
1195
  @editor.pending_count = nil
876
1196
  @pending_keys = []
877
1197
  @editor.echo(name == :delete ? "d" : name.to_s)
@@ -946,6 +1266,14 @@ module RuVim
946
1266
  @editor.echo("q")
947
1267
  end
948
1268
 
1269
+ def toggle_macro_recording_or_start_pending
1270
+ if @editor.macro_recording?
1271
+ stop_macro_recording
1272
+ else
1273
+ start_macro_record_pending
1274
+ end
1275
+ end
1276
+
949
1277
  def finish_macro_record_pending(token)
950
1278
  @macro_record_pending = false
951
1279
  if token == "\e"
@@ -993,7 +1321,7 @@ module RuVim
993
1321
  return
994
1322
  end
995
1323
 
996
- count = @editor.pending_count || 1
1324
+ count = @editor.pending_count
997
1325
  @editor.pending_count = nil
998
1326
  play_macro(name, count:)
999
1327
  end
@@ -1015,7 +1343,7 @@ module RuVim
1015
1343
  @last_macro_name = reg
1016
1344
  @macro_play_stack << reg
1017
1345
  @suspend_macro_recording_depth = (@suspend_macro_recording_depth || 0) + 1
1018
- count.times do
1346
+ [count.to_i, 1].max.times do
1019
1347
  keys.each { |k| handle_key(dup_macro_runtime_key(k)) }
1020
1348
  end
1021
1349
  @editor.echo("@#{reg}")
@@ -1046,7 +1374,7 @@ module RuVim
1046
1374
 
1047
1375
  def handle_operator_pending_key(token)
1048
1376
  op = @operator_pending
1049
- if %w[i a].include?(token) && !op[:motion_prefix]
1377
+ if %w[i a g].include?(token) && !op[:motion_prefix]
1050
1378
  @operator_pending[:motion_prefix] = token
1051
1379
  @editor.echo("#{op[:name].to_s[0]}#{token}")
1052
1380
  return
@@ -1086,6 +1414,18 @@ module RuVim
1086
1414
  return
1087
1415
  end
1088
1416
 
1417
+ if op[:name] == :indent && motion == "="
1418
+ inv = CommandInvocation.new(id: "buffer.indent_lines", count: op[:count])
1419
+ @dispatcher.dispatch(@editor, inv)
1420
+ return
1421
+ end
1422
+
1423
+ if op[:name] == :indent
1424
+ inv = CommandInvocation.new(id: "buffer.indent_motion", count: op[:count], kwargs: { motion: motion })
1425
+ @dispatcher.dispatch(@editor, inv)
1426
+ return
1427
+ end
1428
+
1089
1429
  if op[:name] == :change && motion == "c"
1090
1430
  inv = CommandInvocation.new(id: "buffer.change_line", count: op[:count])
1091
1431
  @dispatcher.dispatch(@editor, inv)
@@ -1104,7 +1444,7 @@ module RuVim
1104
1444
  end
1105
1445
 
1106
1446
  def start_replace_pending
1107
- @replace_pending = { count: (@editor.pending_count || 1) }
1447
+ @replace_pending = { count: @editor.pending_count }
1108
1448
  @editor.pending_count = nil
1109
1449
  @pending_keys = []
1110
1450
  @editor.echo("r")
@@ -1145,9 +1485,9 @@ module RuVim
1145
1485
  return if (@dot_replay_depth || 0).positive?
1146
1486
 
1147
1487
  case invocation.id
1148
- when "buffer.delete_char", "buffer.paste_after", "buffer.paste_before"
1488
+ when "buffer.delete_char", "buffer.delete_motion", "buffer.join_lines", "buffer.swapcase_char", "buffer.paste_after", "buffer.paste_before"
1149
1489
  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"
1490
+ 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
1491
  begin_dot_change_capture(count_prefixed_keys(count, matched_keys)) if @editor.mode == :insert
1152
1492
  end
1153
1493
  end
@@ -1192,7 +1532,7 @@ module RuVim
1192
1532
  @find_pending = {
1193
1533
  direction: (token == "f" || token == "t") ? :forward : :backward,
1194
1534
  till: (token == "t" || token == "T"),
1195
- count: (@editor.pending_count || 1)
1535
+ count: @editor.pending_count
1196
1536
  }
1197
1537
  @editor.pending_count = nil
1198
1538
  @pending_keys = []
@@ -1237,7 +1577,7 @@ module RuVim
1237
1577
  else
1238
1578
  last[:direction]
1239
1579
  end
1240
- count = @editor.pending_count || 1
1580
+ count = @editor.pending_count
1241
1581
  @editor.pending_count = nil
1242
1582
  @pending_keys = []
1243
1583
  moved = perform_find_on_line(char: last[:char], direction:, till: last[:till], count:)
@@ -1251,7 +1591,7 @@ module RuVim
1251
1591
  pos = win.cursor_x
1252
1592
  target = nil
1253
1593
 
1254
- count.times do
1594
+ [count.to_i, 1].max.times do
1255
1595
  idx =
1256
1596
  if direction == :forward
1257
1597
  line.index(char, pos + 1)
@@ -1305,6 +1645,66 @@ module RuVim
1305
1645
  @cmdline_history_index = nil
1306
1646
  end
1307
1647
 
1648
+ def load_command_line_history!
1649
+ path = command_line_history_file_path
1650
+ return unless path
1651
+ return unless File.file?(path)
1652
+
1653
+ raw = File.read(path)
1654
+ data = JSON.parse(raw)
1655
+ return unless data.is_a?(Hash)
1656
+
1657
+ loaded = Hash.new { |h, k| h[k] = [] }
1658
+ data.each do |prefix, items|
1659
+ key = prefix.to_s
1660
+ next unless [":", "/", "?"].include?(key)
1661
+ next unless items.is_a?(Array)
1662
+
1663
+ hist = loaded[key]
1664
+ items.each do |item|
1665
+ text = item.to_s
1666
+ next if text.empty?
1667
+
1668
+ hist.delete(text)
1669
+ hist << text
1670
+ end
1671
+ hist.shift while hist.length > 100
1672
+ end
1673
+ @cmdline_history = loaded
1674
+ rescue StandardError => e
1675
+ verbose_log(1, "history load error: #{e.message}")
1676
+ end
1677
+
1678
+ def save_command_line_history!
1679
+ path = command_line_history_file_path
1680
+ return unless path
1681
+
1682
+ payload = {
1683
+ ":" => Array(@cmdline_history[":"]).map(&:to_s).last(100),
1684
+ "/" => Array(@cmdline_history["/"]).map(&:to_s).last(100),
1685
+ "?" => Array(@cmdline_history["?"]).map(&:to_s).last(100)
1686
+ }
1687
+
1688
+ FileUtils.mkdir_p(File.dirname(path))
1689
+ tmp = "#{path}.tmp"
1690
+ File.write(tmp, JSON.pretty_generate(payload) + "\n")
1691
+ File.rename(tmp, path)
1692
+ rescue StandardError => e
1693
+ verbose_log(1, "history save error: #{e.message}")
1694
+ end
1695
+
1696
+ def command_line_history_file_path
1697
+ xdg_state_home = ENV["XDG_STATE_HOME"].to_s
1698
+ if !xdg_state_home.empty?
1699
+ return File.join(xdg_state_home, "ruvim", "history.json")
1700
+ end
1701
+
1702
+ home = ENV["HOME"].to_s
1703
+ return nil if home.empty?
1704
+
1705
+ File.join(home, ".ruvim", "history.json")
1706
+ end
1707
+
1308
1708
  def command_line_history_move(delta)
1309
1709
  cmd = @editor.command_line
1310
1710
  hist = @cmdline_history[cmd.prefix]
@@ -1333,7 +1733,7 @@ module RuVim
1333
1733
  ctx = ex_completion_context(cmd)
1334
1734
  return unless ctx
1335
1735
 
1336
- matches = ex_completion_candidates(ctx)
1736
+ matches = reusable_command_line_completion_matches(cmd, ctx) || ex_completion_candidates(ctx)
1337
1737
  case matches.length
1338
1738
  when 0
1339
1739
  clear_command_line_completion
@@ -1347,6 +1747,29 @@ module RuVim
1347
1747
  update_incsearch_preview_if_needed
1348
1748
  end
1349
1749
 
1750
+ def reusable_command_line_completion_matches(cmd, ctx)
1751
+ state = @cmdline_completion
1752
+ return nil unless state
1753
+ return nil unless state[:prefix] == cmd.prefix
1754
+ return nil unless state[:kind] == ctx[:kind]
1755
+ return nil unless state[:command] == ctx[:command]
1756
+ return nil unless state[:arg_index] == ctx[:arg_index]
1757
+ return nil unless state[:token_start] == ctx[:token_start]
1758
+
1759
+ before_text = cmd.text[0...ctx[:token_start]].to_s
1760
+ after_text = cmd.text[ctx[:token_end]..].to_s
1761
+ return nil unless state[:before_text] == before_text
1762
+ return nil unless state[:after_text] == after_text
1763
+
1764
+ matches = Array(state[:matches]).map(&:to_s)
1765
+ return nil if matches.empty?
1766
+
1767
+ current_token = cmd.text[ctx[:token_start]...ctx[:token_end]].to_s
1768
+ return nil unless current_token.empty? || matches.include?(current_token) || common_prefix(matches).start_with?(current_token) || current_token.start_with?(common_prefix(matches))
1769
+
1770
+ matches
1771
+ end
1772
+
1350
1773
  def clear_command_line_completion
1351
1774
  @cmdline_completion = nil
1352
1775
  end
@@ -1419,13 +1842,56 @@ module RuVim
1419
1842
  def show_command_line_completion_menu(matches, selected:, force:)
1420
1843
  return unless force || @editor.effective_option("wildmenu")
1421
1844
 
1422
- limit = [@editor.effective_option("pumheight").to_i, 1].max
1423
- items = matches.first(limit).each_with_index.map do |m, i|
1845
+ items = matches.each_with_index.map do |m, i|
1424
1846
  idx = i
1425
1847
  idx == selected ? "[#{m}]" : m
1426
1848
  end
1427
- items << "..." if matches.length > limit
1428
- @editor.echo(items.join(" "))
1849
+ @editor.echo(compose_command_line_completion_menu(items))
1850
+ end
1851
+
1852
+ def compose_command_line_completion_menu(items)
1853
+ parts = Array(items).map(&:to_s)
1854
+ return "" if parts.empty?
1855
+
1856
+ width = command_line_completion_menu_width
1857
+ width = [width.to_i, 1].max
1858
+ out = +""
1859
+ shown = 0
1860
+
1861
+ parts.each_with_index do |item, idx|
1862
+ token = shown.zero? ? item : " #{item}"
1863
+ if out.empty? && token.length > width
1864
+ out = token[0, width]
1865
+ shown = 1
1866
+ break
1867
+ end
1868
+ break if out.length + token.length > width
1869
+
1870
+ out << token
1871
+ shown = idx + 1
1872
+ end
1873
+
1874
+ if shown < parts.length
1875
+ ellipsis = (out.empty? ? "..." : " ...")
1876
+ if out.length + ellipsis.length <= width
1877
+ out << ellipsis
1878
+ elsif width >= 3
1879
+ out = out[0, width - 3] + "..."
1880
+ else
1881
+ out = "." * width
1882
+ end
1883
+ end
1884
+
1885
+ out
1886
+ end
1887
+
1888
+ def command_line_completion_menu_width
1889
+ return 80 unless defined?(@terminal) && @terminal && @terminal.respond_to?(:winsize)
1890
+
1891
+ _rows, cols = @terminal.winsize
1892
+ [cols.to_i, 1].max
1893
+ rescue StandardError
1894
+ 80
1429
1895
  end
1430
1896
 
1431
1897
  def common_prefix(strings)
@@ -1464,6 +1930,7 @@ module RuVim
1464
1930
  end
1465
1931
 
1466
1932
  def apply_insert_autoindent(row, x, previous_row:)
1933
+ return x if @paste_batch
1467
1934
  buf = @editor.current_buffer
1468
1935
  win = @editor.current_window
1469
1936
  return x unless @editor.effective_option("autoindent", window: win, buffer: buf)
@@ -1473,7 +1940,11 @@ module RuVim
1473
1940
  indent = prev[/\A[ \t]*/].to_s
1474
1941
  if @editor.effective_option("smartindent", window: win, buffer: buf)
1475
1942
  trimmed = prev.rstrip
1476
- if trimmed.end_with?("{", "[", "(")
1943
+ needs_indent = trimmed.end_with?("{", "[", "(")
1944
+ if !needs_indent
1945
+ needs_indent = buf.lang_module.indent_trigger?(trimmed)
1946
+ end
1947
+ if needs_indent
1477
1948
  sw = @editor.effective_option("shiftwidth", window: win, buffer: buf).to_i
1478
1949
  sw = effective_tabstop(win, buf) if sw <= 0
1479
1950
  sw = 2 if sw <= 0
@@ -1495,6 +1966,34 @@ module RuVim
1495
1966
  @editor.echo_temporary("match", duration_seconds: mt * 0.1)
1496
1967
  end
1497
1968
 
1969
+ def maybe_dedent_after_insert(key)
1970
+ return unless @editor.effective_option("smartindent", window: @editor.current_window, buffer: @editor.current_buffer)
1971
+
1972
+ buf = @editor.current_buffer
1973
+ lang_mod = buf.lang_module
1974
+
1975
+ pattern = lang_mod.dedent_trigger(key)
1976
+ return unless pattern
1977
+
1978
+ row = @editor.current_window.cursor_y
1979
+ line = buf.line_at(row)
1980
+ m = line.match(pattern)
1981
+ return unless m
1982
+
1983
+ sw = @editor.effective_option("shiftwidth", buffer: buf).to_i
1984
+ sw = 2 if sw <= 0
1985
+ target_indent = lang_mod.calculate_indent(buf.lines, row, sw)
1986
+ return unless target_indent
1987
+
1988
+ current_indent = m[1].length
1989
+ return if current_indent == target_indent
1990
+
1991
+ stripped = line.to_s.strip
1992
+ buf.delete_span(row, 0, row, current_indent) if current_indent > 0
1993
+ buf.insert_text(row, 0, " " * target_indent) if target_indent > 0
1994
+ @editor.current_window.cursor_x = target_indent + stripped.length
1995
+ end
1996
+
1498
1997
  def clear_expired_transient_message_if_any
1499
1998
  @needs_redraw = true if @editor.clear_expired_transient_message!(now: monotonic_now)
1500
1999
  end
@@ -1893,6 +2392,10 @@ module RuVim
1893
2392
  return option_completion_candidates(prefix)
1894
2393
  end
1895
2394
 
2395
+ if cmd == "git"
2396
+ return Git::Handler::GIT_SUBCOMMANDS.keys.sort.select { |s| s.start_with?(prefix) }
2397
+ end
2398
+
1896
2399
  []
1897
2400
  end
1898
2401
 
@@ -1906,15 +2409,27 @@ module RuVim
1906
2409
  else
1907
2410
  File.dirname(input)
1908
2411
  end
1909
- base_dir = "." if base_dir == "."
1910
2412
  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|
2413
+ pattern =
2414
+ if input.empty?
2415
+ "*"
2416
+ elsif base_dir == "."
2417
+ "#{partial}*"
2418
+ else
2419
+ File.join(base_dir, "#{partial}*")
2420
+ end
2421
+ partial_starts_with_dot = partial.start_with?(".")
2422
+ entries = Dir.glob(pattern, File::FNM_DOTMATCH).filter_map do |p|
1913
2423
  next if [".", ".."].include?(File.basename(p))
1914
2424
  next unless p.start_with?(input) || input.empty?
1915
2425
  next if wildignore_path?(p)
1916
2426
  File.directory?(p) ? "#{p}/" : p
1917
2427
  end
2428
+ entries.sort_by do |p|
2429
+ base = File.basename(p.to_s.sub(%r{/\z}, ""))
2430
+ hidden_rank = (!partial_starts_with_dot && base.start_with?(".")) ? 1 : 0
2431
+ [hidden_rank, p]
2432
+ end
1918
2433
  rescue StandardError
1919
2434
  []
1920
2435
  end
@@ -2066,6 +2581,17 @@ module RuVim
2066
2581
  @editor.echo("readonly: #{buf.display_name}")
2067
2582
  end
2068
2583
 
2584
+ def apply_startup_follow!
2585
+ buf = @editor.current_buffer
2586
+ return unless buf&.file_buffer?
2587
+ return if @follow_watchers[buf.id]
2588
+
2589
+ win = @editor.current_window
2590
+ win.cursor_y = buf.line_count - 1
2591
+ win.clamp_to_buffer(buf)
2592
+ start_follow!(buf)
2593
+ end
2594
+
2069
2595
  def apply_startup_nomodifiable!
2070
2596
  buf = @editor.current_buffer
2071
2597
  return unless buf&.file_buffer?
@@ -2096,35 +2622,604 @@ module RuVim
2096
2622
  list = Array(paths).compact
2097
2623
  return if list.empty?
2098
2624
 
2625
+ # Remove the bootstrap empty buffer and reset the ID counter
2626
+ # so the first file gets buffer id 1 (Vim-like behavior).
2627
+ evict_bootstrap_buffer!
2628
+
2629
+ # Initialize arglist with all paths
2630
+ @editor.set_arglist(list)
2631
+
2099
2632
  first, *rest = list
2100
2633
  @editor.open_path(first)
2101
2634
  apply_startup_readonly! if @startup_readonly
2102
2635
  apply_startup_nomodifiable! if @startup_nomodifiable
2636
+ apply_startup_follow! if @startup_follow
2103
2637
 
2104
2638
  case @startup_open_layout
2105
2639
  when :horizontal
2640
+ first_win_id = @editor.current_window_id
2106
2641
  rest.each { |p| open_path_in_split!(p, layout: :horizontal) }
2642
+ @editor.focus_window(first_win_id)
2107
2643
  when :vertical
2644
+ first_win_id = @editor.current_window_id
2108
2645
  rest.each { |p| open_path_in_split!(p, layout: :vertical) }
2646
+ @editor.focus_window(first_win_id)
2109
2647
  when :tab
2110
2648
  rest.each { |p| open_path_in_tab!(p) }
2649
+ @editor.tabnext(-(@editor.tabpage_count - 1))
2111
2650
  else
2112
- # No multi-file layout mode yet; ignore extras if called directly.
2651
+ # Load remaining files as buffers (Vim-like behavior).
2652
+ rest.each do |p|
2653
+ buf = @editor.add_buffer_from_file(p)
2654
+ start_follow!(buf) if @startup_follow
2655
+ end
2656
+ end
2657
+ end
2658
+
2659
+ # Remove the bootstrap empty buffer before opening real files,
2660
+ # resetting the buffer ID counter so the first file gets id 1.
2661
+ def evict_bootstrap_buffer!
2662
+ bid = @editor.buffer_ids.find do |id|
2663
+ b = @editor.buffers[id]
2664
+ b.path.nil? && !b.modified? && b.line_count <= 1 && b.kind == :file
2113
2665
  end
2666
+ return unless bid
2667
+
2668
+ @editor.buffers.delete(bid)
2669
+ @editor.instance_variable_set(:@next_buffer_id, 1)
2114
2670
  end
2115
2671
 
2116
2672
  def open_path_in_split!(path, layout:)
2117
2673
  @editor.split_current_window(layout:)
2118
- buf = @editor.add_buffer_from_file(path)
2119
- @editor.switch_to_buffer(buf.id)
2674
+ @editor.open_path(path)
2120
2675
  apply_startup_readonly! if @startup_readonly
2121
2676
  apply_startup_nomodifiable! if @startup_nomodifiable
2677
+ apply_startup_follow! if @startup_follow
2122
2678
  end
2123
2679
 
2124
2680
  def open_path_in_tab!(path)
2125
2681
  @editor.tabnew(path:)
2126
2682
  apply_startup_readonly! if @startup_readonly
2127
2683
  apply_startup_nomodifiable! if @startup_nomodifiable
2684
+ apply_startup_follow! if @startup_follow
2685
+ end
2686
+
2687
+ def open_path_with_large_file_support(path)
2688
+ return @editor.open_path_sync(path) unless should_open_path_async?(path)
2689
+ return @editor.open_path_sync(path) unless can_start_async_file_load?
2690
+
2691
+ open_path_asynchronously!(path)
2692
+ end
2693
+
2694
+ def should_open_path_async?(path)
2695
+ p = path.to_s
2696
+ return false if p.empty?
2697
+ return false unless File.file?(p)
2698
+
2699
+ File.size(p) >= large_file_async_threshold_bytes
2700
+ rescue StandardError
2701
+ false
2702
+ end
2703
+
2704
+ def can_start_async_file_load?
2705
+ @async_file_loads.empty?
2706
+ end
2707
+
2708
+ def large_file_async_threshold_bytes
2709
+ raw = ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"]
2710
+ n = raw.to_i if raw
2711
+ return n if n && n.positive?
2712
+
2713
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES
2714
+ end
2715
+
2716
+ def open_path_asynchronously!(path)
2717
+ file_size = File.size(path)
2718
+ buf = @editor.add_empty_buffer(path: path)
2719
+ @editor.switch_to_buffer(buf.id)
2720
+ buf.loading_state = :live
2721
+ buf.modified = false
2722
+
2723
+ ensure_stream_event_queue!
2724
+ io = File.open(path, "rb")
2725
+ state = { path: path, io: io, thread: nil, ended_with_newline: false }
2726
+ staged_prefix_bytes = async_file_staged_prefix_bytes
2727
+ staged_mode = file_size > staged_prefix_bytes
2728
+ if staged_mode
2729
+ prefix = io.read(staged_prefix_bytes) || "".b
2730
+ unless prefix.empty?
2731
+ buf.append_stream_text!(Buffer.decode_text(prefix))
2732
+ state[:ended_with_newline] = prefix.end_with?("\n")
2733
+ end
2734
+ end
2735
+
2736
+ if io.eof?
2737
+ buf.finalize_async_file_load!(ended_with_newline: state[:ended_with_newline])
2738
+ buf.loading_state = :closed
2739
+ io.close unless io.closed?
2740
+ return buf
2741
+ end
2742
+
2743
+ @async_file_loads[buf.id] = state
2744
+ state[:thread] = start_async_file_loader_thread(buf.id, io, bulk_once: staged_mode)
2745
+
2746
+ size_mb = file_size.fdiv(1024 * 1024)
2747
+ if staged_mode
2748
+ @editor.echo(format("\"%s\" loading... (showing first %.0fMB of %.1fMB)", path, staged_prefix_bytes.fdiv(1024 * 1024), size_mb))
2749
+ else
2750
+ @editor.echo(format("\"%s\" loading... (%.1fMB)", path, size_mb))
2751
+ end
2752
+ buf
2753
+ rescue StandardError
2754
+ @async_file_loads.delete(buf.id) if buf
2755
+ raise
2756
+ end
2757
+
2758
+ def async_file_staged_prefix_bytes
2759
+ raw = ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"]
2760
+ n = raw.to_i if raw
2761
+ return n if n && n.positive?
2762
+
2763
+ LARGE_FILE_STAGED_PREFIX_BYTES
2764
+ end
2765
+
2766
+ def start_async_file_loader_thread(buffer_id, io, bulk_once: false)
2767
+ Thread.new do
2768
+ if bulk_once
2769
+ rest = io.read || "".b
2770
+ unless rest.empty?
2771
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: Buffer.decode_text(rest) }
2772
+ notify_signal_wakeup
2773
+ end
2774
+ @stream_event_queue << { type: :file_eof, buffer_id: buffer_id, ended_with_newline: rest.end_with?("\n") }
2775
+ notify_signal_wakeup
2776
+ next
2777
+ end
2778
+
2779
+ ended_with_newline = false
2780
+ pending_text = +""
2781
+ loop do
2782
+ chunk = io.readpartial(ASYNC_FILE_READ_CHUNK_BYTES)
2783
+ next if chunk.nil? || chunk.empty?
2784
+
2785
+ ended_with_newline = chunk.end_with?("\n")
2786
+ pending_text << Buffer.decode_text(chunk)
2787
+ next if pending_text.bytesize < ASYNC_FILE_EVENT_FLUSH_BYTES
2788
+
2789
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: pending_text }
2790
+ pending_text = +""
2791
+ notify_signal_wakeup
2792
+ end
2793
+ rescue EOFError
2794
+ unless pending_text.empty?
2795
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: pending_text }
2796
+ notify_signal_wakeup
2797
+ end
2798
+ @stream_event_queue << { type: :file_eof, buffer_id: buffer_id, ended_with_newline: ended_with_newline }
2799
+ notify_signal_wakeup
2800
+ rescue StandardError => e
2801
+ @stream_event_queue << { type: :file_error, buffer_id: buffer_id, error: e.message.to_s }
2802
+ notify_signal_wakeup
2803
+ ensure
2804
+ begin
2805
+ io.close unless io.closed?
2806
+ rescue StandardError
2807
+ nil
2808
+ end
2809
+ end
2810
+ end
2811
+
2812
+ def prepare_stdin_stream_buffer!
2813
+ buf = @editor.current_buffer
2814
+ if buf.intro_buffer?
2815
+ @editor.materialize_intro_buffer!
2816
+ buf = @editor.current_buffer
2817
+ end
2818
+
2819
+ buf.replace_all_lines!([""])
2820
+ buf.configure_special!(kind: :stream, name: "[stdin]", readonly: true, modifiable: false)
2821
+ buf.modified = false
2822
+ buf.stream_state = :live
2823
+ buf.options["filetype"] = "text"
2824
+ @stream_stop_requested = false
2825
+ ensure_stream_event_queue!
2826
+ @stream_buffer_id = buf.id
2827
+ move_window_to_stream_end!(@editor.current_window, buf)
2828
+ @editor.echo("[stdin] follow")
2829
+ end
2830
+
2831
+ def stdin_stream_stop_command
2832
+ return if stop_stdin_stream!
2833
+
2834
+ handle_normal_ctrl_c
2835
+ end
2836
+
2837
+ def stop_stdin_stream!
2838
+ buf = @editor.buffers[@stream_buffer_id]
2839
+ return false unless buf&.kind == :stream
2840
+ return false unless (buf.stream_state || :live) == :live
2841
+
2842
+ @stream_stop_requested = true
2843
+ io = @stdin_stream_source
2844
+ @stdin_stream_source = nil
2845
+ begin
2846
+ io.close if io && io.respond_to?(:close) && !(io.respond_to?(:closed?) && io.closed?)
2847
+ rescue StandardError
2848
+ nil
2849
+ end
2850
+ if @stream_reader_thread&.alive?
2851
+ @stream_reader_thread.kill
2852
+ @stream_reader_thread.join(0.05)
2853
+ end
2854
+ @stream_reader_thread = nil
2855
+
2856
+ buf.stream_state = :closed
2857
+ @editor.echo("[stdin] closed")
2858
+ notify_signal_wakeup
2859
+ true
2860
+ end
2861
+
2862
+ def start_stdin_stream_reader!
2863
+ return unless @stdin_stream_source
2864
+ ensure_stream_event_queue!
2865
+ return if @stream_reader_thread&.alive?
2866
+
2867
+ @stream_stop_requested = false
2868
+ io = @stdin_stream_source
2869
+ @stream_reader_thread = Thread.new do
2870
+ loop do
2871
+ chunk = io.readpartial(4096)
2872
+ next if chunk.nil? || chunk.empty?
2873
+
2874
+ @stream_event_queue << { type: :data, data: Buffer.decode_text(chunk) }
2875
+ notify_signal_wakeup
2876
+ end
2877
+ rescue EOFError
2878
+ unless @stream_stop_requested
2879
+ @stream_event_queue << { type: :eof }
2880
+ notify_signal_wakeup
2881
+ end
2882
+ rescue IOError => e
2883
+ unless @stream_stop_requested
2884
+ @stream_event_queue << { type: :error, error: e.message.to_s }
2885
+ notify_signal_wakeup
2886
+ end
2887
+ rescue StandardError => e
2888
+ unless @stream_stop_requested
2889
+ @stream_event_queue << { type: :error, error: e.message.to_s }
2890
+ notify_signal_wakeup
2891
+ end
2892
+ end
2893
+ end
2894
+
2895
+ def drain_stream_events!
2896
+ return false unless @stream_event_queue
2897
+
2898
+ changed = false
2899
+ loop do
2900
+ event = @stream_event_queue.pop(true)
2901
+ case event[:type]
2902
+ when :data
2903
+ changed = apply_stream_chunk!(event[:data]) || changed
2904
+ when :eof
2905
+ if (buf = @editor.buffers[@stream_buffer_id])
2906
+ buf.stream_state = :closed
2907
+ end
2908
+ @editor.echo("[stdin] EOF")
2909
+ changed = true
2910
+ when :error
2911
+ next if ignore_stream_shutdown_error?(event[:error])
2912
+ if (buf = @editor.buffers[@stream_buffer_id])
2913
+ buf.stream_state = :error
2914
+ end
2915
+ @editor.echo_error("[stdin] stream error: #{event[:error]}")
2916
+ changed = true
2917
+ when :follow_data
2918
+ changed = apply_follow_chunk!(event[:buffer_id], event[:data]) || changed
2919
+ when :follow_truncated
2920
+ if (buf = @editor.buffers[event[:buffer_id]])
2921
+ @editor.echo("[follow] file truncated: #{buf.display_name}")
2922
+ changed = true
2923
+ end
2924
+ when :follow_deleted
2925
+ if (buf = @editor.buffers[event[:buffer_id]])
2926
+ @editor.echo("[follow] file deleted, waiting for re-creation: #{buf.display_name}")
2927
+ changed = true
2928
+ end
2929
+ when :file_data
2930
+ changed = apply_async_file_chunk!(event[:buffer_id], event[:data]) || changed
2931
+ when :file_eof
2932
+ changed = finish_async_file_load!(event[:buffer_id], ended_with_newline: event[:ended_with_newline]) || changed
2933
+ when :file_error
2934
+ changed = fail_async_file_load!(event[:buffer_id], event[:error]) || changed
2935
+ when :git_cmd_data
2936
+ changed = apply_git_stream_chunk!(event[:buffer_id], event[:data]) || changed
2937
+ when :git_cmd_eof
2938
+ changed = finish_git_stream!(event[:buffer_id]) || changed
2939
+ when :git_cmd_error
2940
+ changed = fail_git_stream!(event[:buffer_id], event[:error]) || changed
2941
+ end
2942
+ end
2943
+ rescue ThreadError
2944
+ changed
2945
+ end
2946
+
2947
+ def apply_stream_chunk!(text)
2948
+ return false if text.to_s.empty?
2949
+
2950
+ buf = @editor.buffers[@stream_buffer_id]
2951
+ return false unless buf
2952
+
2953
+ follow_window_ids = @editor.windows.values.filter_map do |win|
2954
+ next unless win.buffer_id == buf.id
2955
+ next unless stream_window_following_end?(win, buf)
2956
+
2957
+ win.id
2958
+ end
2959
+
2960
+ buf.append_stream_text!(text)
2961
+
2962
+ follow_window_ids.each do |win_id|
2963
+ win = @editor.windows[win_id]
2964
+ move_window_to_stream_end!(win, buf) if win
2965
+ end
2966
+
2967
+ true
2968
+ end
2969
+
2970
+ def apply_async_file_chunk!(buffer_id, text)
2971
+ return false if text.to_s.empty?
2972
+
2973
+ buf = @editor.buffers[buffer_id]
2974
+ return false unless buf
2975
+
2976
+ buf.append_stream_text!(text)
2977
+ true
2978
+ end
2979
+
2980
+ def finish_async_file_load!(buffer_id, ended_with_newline:)
2981
+ @async_file_loads.delete(buffer_id)
2982
+ buf = @editor.buffers[buffer_id]
2983
+ return false unless buf
2984
+
2985
+ buf.finalize_async_file_load!(ended_with_newline: !!ended_with_newline)
2986
+ buf.loading_state = :closed
2987
+ true
2988
+ end
2989
+
2990
+ def fail_async_file_load!(buffer_id, error)
2991
+ state = @async_file_loads.delete(buffer_id)
2992
+ buf = @editor.buffers[buffer_id]
2993
+ if buf
2994
+ buf.loading_state = :error
2995
+ end
2996
+ @editor.echo_error("\"#{(state && state[:path]) || (buf && buf.display_name) || buffer_id}\" load error: #{error}")
2997
+ true
2998
+ end
2999
+
3000
+ def stream_window_following_end?(win, buf)
3001
+ return false unless win
3002
+
3003
+ last_row = buf.line_count - 1
3004
+ win.cursor_y >= last_row
3005
+ end
3006
+
3007
+ def move_window_to_stream_end!(win, buf)
3008
+ return unless win && buf
3009
+
3010
+ last_row = buf.line_count - 1
3011
+ win.cursor_y = last_row
3012
+ win.cursor_x = buf.line_length(last_row)
3013
+ win.clamp_to_buffer(buf)
3014
+ end
3015
+
3016
+ def shutdown_stream_reader!
3017
+ thread = @stream_reader_thread
3018
+ @stream_reader_thread = nil
3019
+ @stream_stop_requested = true
3020
+ return unless thread
3021
+ return unless thread.alive?
3022
+
3023
+ thread.kill
3024
+ thread.join(0.05)
3025
+ rescue StandardError
3026
+ nil
3027
+ end
3028
+
3029
+ def ex_follow_toggle
3030
+ buf = @editor.current_buffer
3031
+ raise RuVim::CommandError, "No file associated with buffer" unless buf.path
3032
+
3033
+ if @follow_watchers[buf.id]
3034
+ stop_follow!(buf)
3035
+ else
3036
+ raise RuVim::CommandError, "Buffer has unsaved changes" if buf.modified?
3037
+ start_follow!(buf)
3038
+ end
3039
+ end
3040
+
3041
+ def start_follow!(buf)
3042
+ ensure_stream_event_queue!
3043
+ if buf.path && File.exist?(buf.path)
3044
+ data = File.binread(buf.path)
3045
+ if data.end_with?("\n") && buf.lines.last.to_s != ""
3046
+ following_wins = @editor.windows.values.select do |w|
3047
+ w.buffer_id == buf.id && stream_window_following_end?(w, buf)
3048
+ end
3049
+ buf.append_stream_text!("\n")
3050
+ following_wins.each { |w| move_window_to_stream_end!(w, buf) }
3051
+ end
3052
+ end
3053
+ buffer_id = buf.id
3054
+ watcher = FileWatcher.create(buf.path) do |type, data|
3055
+ case type
3056
+ when :data
3057
+ @stream_event_queue << { type: :follow_data, buffer_id: buffer_id, data: data }
3058
+ when :truncated
3059
+ @stream_event_queue << { type: :follow_truncated, buffer_id: buffer_id }
3060
+ when :deleted
3061
+ @stream_event_queue << { type: :follow_deleted, buffer_id: buffer_id }
3062
+ end
3063
+ notify_signal_wakeup
3064
+ end
3065
+ watcher.start
3066
+ @follow_watchers[buf.id] = watcher
3067
+ buf.stream_state = :live
3068
+ buf.follow_backend = watcher.backend
3069
+ @editor.echo("[follow] #{buf.display_name}")
3070
+ end
3071
+
3072
+ def stop_follow!(buf)
3073
+ watcher = @follow_watchers.delete(buf.id)
3074
+ watcher&.stop
3075
+ # Remove trailing empty line added as sentinel by start_follow!
3076
+ if buf.line_count > 1 && buf.lines.last.to_s == ""
3077
+ buf.lines.pop
3078
+ last = buf.line_count - 1
3079
+ @editor.windows.each_value do |win|
3080
+ next unless win.buffer_id == buf.id
3081
+ win.cursor_y = last if win.cursor_y > last
3082
+ end
3083
+ end
3084
+ buf.stream_state = nil
3085
+ buf.follow_backend = nil
3086
+ @editor.echo("[follow] stopped")
3087
+ end
3088
+
3089
+ def apply_follow_chunk!(buffer_id, text)
3090
+ return false if text.to_s.empty?
3091
+
3092
+ buf = @editor.buffers[buffer_id]
3093
+ return false unless buf
3094
+
3095
+ follow_window_ids = @editor.windows.values.filter_map do |win|
3096
+ next unless win.buffer_id == buf.id
3097
+ next unless stream_window_following_end?(win, buf)
3098
+
3099
+ win.id
3100
+ end
3101
+
3102
+ buf.append_stream_text!(text)
3103
+
3104
+ follow_window_ids.each do |win_id|
3105
+ win = @editor.windows[win_id]
3106
+ move_window_to_stream_end!(win, buf) if win
3107
+ end
3108
+
3109
+ true
3110
+ end
3111
+
3112
+ def shutdown_follow_watchers!
3113
+ watchers = @follow_watchers
3114
+ @follow_watchers = {}
3115
+ watchers.each_value do |watcher|
3116
+ watcher.stop
3117
+ rescue StandardError
3118
+ nil
3119
+ end
3120
+ end
3121
+
3122
+ def shutdown_async_file_loaders!
3123
+ loaders = @async_file_loads
3124
+ @async_file_loads = {}
3125
+ loaders.each_value do |state|
3126
+ io = state[:io]
3127
+ thread = state[:thread]
3128
+ begin
3129
+ io.close if io && !io.closed?
3130
+ rescue StandardError
3131
+ nil
3132
+ end
3133
+ next unless thread&.alive?
3134
+
3135
+ thread.kill
3136
+ thread.join(0.05)
3137
+ rescue StandardError
3138
+ nil
3139
+ end
3140
+ end
3141
+
3142
+ def shutdown_background_readers!
3143
+ shutdown_stream_reader!
3144
+ shutdown_follow_watchers!
3145
+ shutdown_async_file_loaders!
3146
+ end
3147
+
3148
+ def ignore_stream_shutdown_error?(message)
3149
+ buf = @editor.buffers[@stream_buffer_id]
3150
+ return false unless buf&.kind == :stream
3151
+ return false unless (buf.stream_state || :live) == :closed
3152
+
3153
+ msg = message.to_s.downcase
3154
+ msg.include?("stream closed") || msg.include?("closed in another thread")
3155
+ end
3156
+
3157
+ def ensure_stream_event_queue!
3158
+ @stream_event_queue ||= Queue.new
3159
+ end
3160
+
3161
+ def apply_git_stream_chunk!(buffer_id, text)
3162
+ return false if text.to_s.empty?
3163
+
3164
+ buf = @editor.buffers[buffer_id]
3165
+ return false unless buf
3166
+
3167
+ buf.append_stream_text!(text)
3168
+ true
3169
+ end
3170
+
3171
+ def finish_git_stream!(buffer_id)
3172
+ @git_stream_ios&.delete(buffer_id)
3173
+ @git_stream_threads&.delete(buffer_id)
3174
+ buf = @editor.buffers[buffer_id]
3175
+ return false unless buf
3176
+
3177
+ # Remove trailing empty line if present
3178
+ if buf.lines.length > 1 && buf.lines[-1] == ""
3179
+ buf.lines.pop
3180
+ end
3181
+ line_count = buf.line_count
3182
+ @editor.echo("#{buf.name} #{line_count} lines")
3183
+ true
3184
+ end
3185
+
3186
+ def fail_git_stream!(buffer_id, error)
3187
+ @git_stream_ios&.delete(buffer_id)
3188
+ @git_stream_threads&.delete(buffer_id)
3189
+ buf = @editor.buffers[buffer_id]
3190
+ @editor.echo_error("git stream error: #{error}") if buf
3191
+ true
3192
+ end
3193
+
3194
+ def start_git_stream_command(buffer_id, cmd, root)
3195
+ ensure_stream_event_queue!
3196
+ @git_stream_ios ||= {}
3197
+ @git_stream_threads ||= {}
3198
+ queue = @stream_event_queue
3199
+ ios = @git_stream_ios
3200
+ @git_stream_threads[buffer_id] = Thread.new do
3201
+ IO.popen(cmd, chdir: root, err: [:child, :out]) do |io|
3202
+ ios[buffer_id] = io
3203
+ while (chunk = io.read(4096))
3204
+ queue << { type: :git_cmd_data, buffer_id: buffer_id, data: Buffer.decode_text(chunk) }
3205
+ notify_signal_wakeup
3206
+ end
3207
+ end
3208
+ ios.delete(buffer_id)
3209
+ queue << { type: :git_cmd_eof, buffer_id: buffer_id }
3210
+ notify_signal_wakeup
3211
+ rescue StandardError => e
3212
+ ios.delete(buffer_id)
3213
+ queue << { type: :git_cmd_error, buffer_id: buffer_id, error: e.message.to_s }
3214
+ notify_signal_wakeup
3215
+ end
3216
+ end
3217
+
3218
+ def stop_git_stream!(buffer_id)
3219
+ io = @git_stream_ios&.delete(buffer_id)
3220
+ io&.close
3221
+ rescue IOError
3222
+ # already closed
2128
3223
  end
2129
3224
 
2130
3225
  def move_cursor_to_line(line_number)