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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class ExCommandRegistry
3
5
  ExCommandSpec = Struct.new(
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module FileWatcher
5
+ def self.create(path, &on_event)
6
+ if InotifyWatcher.available? && File.exist?(path)
7
+ InotifyWatcher.new(path, &on_event)
8
+ else
9
+ PollingWatcher.new(path, &on_event)
10
+ end
11
+ end
12
+
13
+ class PollingWatcher
14
+ MIN_INTERVAL = 0.1
15
+ MAX_INTERVAL = 3.0
16
+ BACKOFF_FACTOR = 1.5
17
+
18
+ attr_reader :current_interval
19
+
20
+ def backend = :polling
21
+
22
+ def initialize(path, &on_event)
23
+ @path = path
24
+ @on_event = on_event
25
+ @offset = File.exist?(path) ? File.size(path) : 0
26
+ @file_existed = File.exist?(path)
27
+ @current_interval = MIN_INTERVAL
28
+ @thread = nil
29
+ @stop = false
30
+ end
31
+
32
+ def start
33
+ @stop = false
34
+ @thread = Thread.new { poll_loop }
35
+ end
36
+
37
+ def stop
38
+ @stop = true
39
+ @thread&.kill
40
+ @thread&.join(0.5)
41
+ @thread = nil
42
+ end
43
+
44
+ def alive?
45
+ @thread&.alive? || false
46
+ end
47
+
48
+ private
49
+
50
+ def poll_loop
51
+ until @stop
52
+ sleep @current_interval
53
+ check_file
54
+ end
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def check_file
60
+ exists = File.exist?(@path)
61
+
62
+ if !exists
63
+ if @file_existed
64
+ @file_existed = false
65
+ @offset = 0
66
+ @on_event.call(:deleted, nil)
67
+ end
68
+ @current_interval = [@current_interval * BACKOFF_FACTOR, MAX_INTERVAL].min
69
+ return
70
+ end
71
+
72
+ @file_existed = true
73
+ current_size = File.size(@path)
74
+ if current_size > @offset
75
+ data = File.binread(@path, current_size - @offset, @offset)
76
+ @offset = current_size
77
+ @current_interval = MIN_INTERVAL
78
+ @on_event.call(:data, RuVim::Buffer.decode_text(data)) if data && !data.empty?
79
+ elsif current_size < @offset
80
+ @offset = current_size
81
+ @current_interval = MIN_INTERVAL
82
+ @on_event.call(:truncated, nil)
83
+ else
84
+ @current_interval = [@current_interval * BACKOFF_FACTOR, MAX_INTERVAL].min
85
+ end
86
+ end
87
+ end
88
+
89
+ class InotifyWatcher
90
+ IN_MODIFY = 0x00000002
91
+ IN_DELETE_SELF = 0x00000400
92
+
93
+ def backend = :inotify
94
+
95
+ def self.available?
96
+ return @available unless @available.nil?
97
+ @available = begin
98
+ gem "fiddle" if defined?(Gem)
99
+ require "fiddle/import"
100
+ _mod = inotify_module
101
+ true
102
+ rescue LoadError, StandardError
103
+ false
104
+ end
105
+ end
106
+
107
+ def self.inotify_module
108
+ @inotify_module ||= begin
109
+ mod = Module.new do
110
+ extend Fiddle::Importer
111
+ dlload "libc.so.6"
112
+ extern "int inotify_init()"
113
+ extern "int inotify_add_watch(int, const char*, unsigned int)"
114
+ extern "int inotify_rm_watch(int, int)"
115
+ end
116
+ mod
117
+ end
118
+ end
119
+
120
+ def initialize(path, &on_event)
121
+ raise ArgumentError, "File does not exist: #{path}" unless File.exist?(path)
122
+
123
+ @path = path
124
+ @on_event = on_event
125
+ @offset = File.size(path)
126
+ @thread = nil
127
+ @stop = false
128
+ @inotify_io = nil
129
+ @watch_descriptor = nil
130
+ end
131
+
132
+ def start
133
+ @stop = false
134
+ setup_inotify!
135
+ @thread = Thread.new { watch_loop }
136
+ end
137
+
138
+ def stop
139
+ @stop = true
140
+ cleanup_inotify!
141
+ @thread&.join(0.5)
142
+ @thread = nil
143
+ end
144
+
145
+ def alive?
146
+ @thread&.alive? || false
147
+ end
148
+
149
+ private
150
+
151
+ def setup_inotify!
152
+ cleanup_inotify!
153
+ mod = self.class.inotify_module
154
+ fd = mod.inotify_init
155
+ raise "inotify_init failed" if fd < 0
156
+
157
+ @inotify_io = IO.new(fd)
158
+ @watch_descriptor = mod.inotify_add_watch(fd, @path, IN_MODIFY | IN_DELETE_SELF)
159
+ raise "inotify_add_watch failed" if @watch_descriptor < 0
160
+ end
161
+
162
+ def cleanup_inotify!
163
+ if @watch_descriptor && @inotify_io && !@inotify_io.closed?
164
+ begin
165
+ self.class.inotify_module.inotify_rm_watch(@inotify_io.fileno, @watch_descriptor)
166
+ rescue StandardError
167
+ nil
168
+ end
169
+ end
170
+ @watch_descriptor = nil
171
+ begin
172
+ @inotify_io&.close unless @inotify_io&.closed?
173
+ rescue StandardError
174
+ nil
175
+ end
176
+ @inotify_io = nil
177
+ end
178
+
179
+ def watch_loop
180
+ until @stop
181
+ ready = IO.select([@inotify_io], nil, nil, 1.0)
182
+ next unless ready
183
+
184
+ begin
185
+ event_data = @inotify_io.read_nonblock(4096)
186
+ rescue IO::WaitReadable
187
+ next
188
+ end
189
+
190
+ if file_deleted_event?(event_data)
191
+ @offset = 0
192
+ @on_event.call(:deleted, nil)
193
+ wait_for_file_and_rewatch!
194
+ next
195
+ end
196
+
197
+ read_new_data
198
+ end
199
+ rescue IOError, Errno::EBADF
200
+ nil
201
+ end
202
+
203
+ def file_deleted_event?(event_data)
204
+ return false unless event_data && event_data.bytesize >= 8
205
+
206
+ # inotify_event struct: int wd, uint32_t mask, uint32_t cookie, uint32_t len, char name[]
207
+ # mask is at offset 4, 4 bytes little-endian
208
+ mask = event_data.byteslice(4, 4).unpack1("V")
209
+ (mask & IN_DELETE_SELF) != 0
210
+ end
211
+
212
+ def wait_for_file_and_rewatch!
213
+ cleanup_inotify!
214
+ interval = PollingWatcher::MIN_INTERVAL
215
+ until @stop
216
+ sleep interval
217
+ if File.exist?(@path)
218
+ @offset = 0
219
+ setup_inotify!
220
+ return
221
+ end
222
+ interval = [interval * PollingWatcher::BACKOFF_FACTOR, PollingWatcher::MAX_INTERVAL].min
223
+ end
224
+ rescue StandardError
225
+ nil
226
+ end
227
+
228
+ def read_new_data
229
+ return unless File.exist?(@path)
230
+
231
+ current_size = File.size(@path)
232
+ if current_size > @offset
233
+ data = File.binread(@path, current_size - @offset, @offset)
234
+ @offset = current_size
235
+ @on_event.call(:data, RuVim::Buffer.decode_text(data)) if data && !data.empty?
236
+ elsif current_size < @offset
237
+ @offset = current_size
238
+ @on_event.call(:truncated, nil)
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Blame
8
+ module_function
9
+
10
+ # Parse `git blame --porcelain` output into an array of entry hashes.
11
+ # Each entry: { hash:, short_hash:, author:, date:, summary:, text:, orig_line: }
12
+ def parse_porcelain(output)
13
+ entries = []
14
+ commit_cache = {}
15
+ current = nil
16
+
17
+ output.each_line(chomp: true) do |line|
18
+ if line.start_with?("\t")
19
+ current[:text] = line[1..]
20
+ entries << current
21
+ current = nil
22
+ elsif current.nil?
23
+ parts = line.split(" ")
24
+ hash = parts[0]
25
+ orig_line = parts[1].to_i
26
+
27
+ if commit_cache.key?(hash)
28
+ current = commit_cache[hash].dup
29
+ current[:orig_line] = orig_line
30
+ else
31
+ current = { hash: hash, short_hash: hash[0, 8], orig_line: orig_line }
32
+ end
33
+ else
34
+ case line
35
+ when /\Aauthor (.+)/
36
+ current[:author] = $1
37
+ when /\Aauthor-time (\d+)/
38
+ current[:date] = Time.at($1.to_i).strftime("%Y-%m-%d")
39
+ when /\Asummary (.+)/
40
+ current[:summary] = $1
41
+ when /\Afilename (.+)/
42
+ commit_cache[current[:hash]] ||= current.dup
43
+ end
44
+ end
45
+ end
46
+
47
+ entries
48
+ end
49
+
50
+ # Format entries into display lines for the blame buffer.
51
+ def format_lines(entries)
52
+ max_author = entries.map { |e| e[:author].to_s.length }.max || 0
53
+ max_author = [max_author, 20].min
54
+
55
+ entries.map do |e|
56
+ author = (e[:author] || "").ljust(max_author)[0, max_author]
57
+ "#{e[:short_hash]} #{author} #{e[:date]} #{e[:text]}"
58
+ end
59
+ end
60
+
61
+ # Run git blame for a file at a given revision.
62
+ # Returns [entries, error_message].
63
+ def run(file_path, rev: nil)
64
+ dir = File.dirname(file_path)
65
+ basename = File.basename(file_path)
66
+
67
+ cmd = ["git", "blame", "--porcelain"]
68
+ if rev
69
+ cmd << rev << "--" << basename
70
+ else
71
+ cmd << "--" << basename
72
+ end
73
+
74
+ out, err, status = Open3.capture3(*cmd, chdir: dir)
75
+ unless status.success?
76
+ return [nil, err.strip]
77
+ end
78
+
79
+ entries = parse_porcelain(out)
80
+ [entries, nil]
81
+ end
82
+
83
+ # Run git show for a commit.
84
+ # Returns [lines, error_message].
85
+ def show_commit(file_path, commit_hash)
86
+ dir = File.dirname(file_path)
87
+ out, err, status = Open3.capture3("git", "show", commit_hash, chdir: dir)
88
+ unless status.success?
89
+ return [nil, err.strip]
90
+ end
91
+ [out.lines(chomp: true), nil]
92
+ end
93
+
94
+ # Command handler methods (included via Git::Handler)
95
+ module HandlerMethods
96
+ def git_blame(ctx, **)
97
+ source_buf = ctx.buffer
98
+ unless source_buf.path && File.exist?(source_buf.path)
99
+ ctx.editor.echo_error("No file to blame")
100
+ return
101
+ end
102
+
103
+ entries, err = Blame.run(source_buf.path)
104
+ unless entries
105
+ ctx.editor.echo_error("git blame: #{err}")
106
+ return
107
+ end
108
+
109
+ lines = Blame.format_lines(entries)
110
+ cursor_y = ctx.window.cursor_y
111
+
112
+ blame_buf = ctx.editor.add_virtual_buffer(
113
+ kind: :blame,
114
+ name: "[Blame] #{File.basename(source_buf.path)}",
115
+ lines: lines,
116
+ readonly: true,
117
+ modifiable: false
118
+ )
119
+ blame_buf.options["blame_entries"] = entries
120
+ blame_buf.options["blame_source_path"] = source_buf.path
121
+ blame_buf.options["blame_history"] = []
122
+
123
+ ctx.editor.switch_to_buffer(blame_buf.id)
124
+ ctx.window.cursor_y = [cursor_y, lines.length - 1].min
125
+
126
+ bind_git_buffer_keys(ctx.editor, blame_buf.id)
127
+ bind_blame_keys(ctx.editor, blame_buf.id)
128
+ ctx.editor.echo("[Blame] #{File.basename(source_buf.path)}")
129
+ end
130
+
131
+ def git_blame_prev(ctx, **)
132
+ buf = ctx.buffer
133
+ unless buf.kind == :blame
134
+ ctx.editor.echo_error("Not a blame buffer")
135
+ return
136
+ end
137
+
138
+ entries = buf.options["blame_entries"]
139
+ source_path = buf.options["blame_source_path"]
140
+ history = buf.options["blame_history"]
141
+ cursor_y = ctx.window.cursor_y
142
+ entry = entries[cursor_y]
143
+
144
+ unless entry
145
+ ctx.editor.echo_error("No blame entry on this line")
146
+ return
147
+ end
148
+
149
+ commit_hash = entry[:hash]
150
+ if commit_hash.start_with?("0000000")
151
+ ctx.editor.echo_error("Uncommitted changes — cannot go further back")
152
+ return
153
+ end
154
+
155
+ new_entries, err = Blame.run(source_path, rev: "#{commit_hash}^")
156
+ unless new_entries
157
+ ctx.editor.echo_error("git blame: #{err}")
158
+ return
159
+ end
160
+
161
+ history.push({ entries: entries, cursor_y: cursor_y })
162
+
163
+ new_lines = Blame.format_lines(new_entries)
164
+ buf.instance_variable_set(:@lines, new_lines)
165
+ buf.options["blame_entries"] = new_entries
166
+ ctx.window.cursor_y = [cursor_y, new_lines.length - 1].min
167
+ ctx.window.cursor_x = 0
168
+ ctx.editor.echo("[Blame] #{commit_hash[0, 8]}^")
169
+ end
170
+
171
+ def git_blame_back(ctx, **)
172
+ buf = ctx.buffer
173
+ unless buf.kind == :blame
174
+ ctx.editor.echo_error("Not a blame buffer")
175
+ return
176
+ end
177
+
178
+ history = buf.options["blame_history"]
179
+ if history.nil? || history.empty?
180
+ ctx.editor.echo_error("No blame history to go back to")
181
+ return
182
+ end
183
+
184
+ state = history.pop
185
+ lines = Blame.format_lines(state[:entries])
186
+ buf.instance_variable_set(:@lines, lines)
187
+ buf.options["blame_entries"] = state[:entries]
188
+ ctx.window.cursor_y = [state[:cursor_y], lines.length - 1].min
189
+ ctx.window.cursor_x = 0
190
+ ctx.editor.echo("[Blame] restored")
191
+ end
192
+
193
+ def git_blame_commit(ctx, **)
194
+ buf = ctx.buffer
195
+ unless buf.kind == :blame
196
+ ctx.editor.echo_error("Not a blame buffer")
197
+ return
198
+ end
199
+
200
+ entries = buf.options["blame_entries"]
201
+ source_path = buf.options["blame_source_path"]
202
+ entry = entries[ctx.window.cursor_y]
203
+
204
+ unless entry
205
+ ctx.editor.echo_error("No blame entry on this line")
206
+ return
207
+ end
208
+
209
+ commit_hash = entry[:hash]
210
+ if commit_hash.start_with?("0000000")
211
+ ctx.editor.echo_error("Uncommitted changes — no commit to show")
212
+ return
213
+ end
214
+
215
+ lines, err = Blame.show_commit(source_path, commit_hash)
216
+ unless lines
217
+ ctx.editor.echo_error("git show: #{err}")
218
+ return
219
+ end
220
+
221
+ show_buf = ctx.editor.add_virtual_buffer(
222
+ kind: :git_show,
223
+ name: "[Commit] #{commit_hash[0, 8]}",
224
+ lines: lines,
225
+ filetype: "diff",
226
+ readonly: true,
227
+ modifiable: false
228
+ )
229
+ ctx.editor.switch_to_buffer(show_buf.id)
230
+ bind_git_buffer_keys(ctx.editor, show_buf.id)
231
+ ctx.editor.echo("[Commit] #{commit_hash[0, 8]}")
232
+ end
233
+
234
+ private
235
+
236
+ def bind_blame_keys(editor, buffer_id)
237
+ km = editor.keymap_manager
238
+ km.bind_buffer(buffer_id, "p", "git.blame.prev")
239
+ km.bind_buffer(buffer_id, "P", "git.blame.back")
240
+ km.bind_buffer(buffer_id, "c", "git.blame.commit")
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Branch
8
+ module_function
9
+
10
+ # Run git branch listing sorted by most recent commit.
11
+ # Returns [lines, root, error_message].
12
+ def run(file_path)
13
+ root, err = Git.repo_root(file_path)
14
+ return [nil, nil, err] unless root
15
+
16
+ out, err, status = Open3.capture3(
17
+ "git", "branch", "-a",
18
+ "--sort=-committerdate",
19
+ "--format=%(if)%(HEAD)%(then)* %(else) %(end)%(refname:short)\t%(committerdate:short)\t%(subject)",
20
+ chdir: root
21
+ )
22
+ unless status.success?
23
+ return [nil, nil, err.strip]
24
+ end
25
+ [out.lines(chomp: true), root, nil]
26
+ end
27
+
28
+ # Parse branch name from a branch list line.
29
+ # Returns branch name or nil.
30
+ def parse_branch_name(line)
31
+ stripped = line.to_s.strip
32
+ return nil if stripped.empty?
33
+
34
+ # Remove leading "* " marker for current branch
35
+ name = stripped.sub(/\A\*\s*/, "")
36
+ # Branch name is everything before the first tab
37
+ name = name.split("\t", 2).first
38
+ name&.strip
39
+ end
40
+
41
+ # Command handler methods
42
+ module HandlerMethods
43
+ def git_branch(ctx, **)
44
+ file_path = git_resolve_path(ctx)
45
+ unless file_path
46
+ ctx.editor.echo_error("No file or directory to resolve git repo")
47
+ return
48
+ end
49
+
50
+ lines, root, err = Branch.run(file_path)
51
+ unless lines
52
+ ctx.editor.echo_error("git branch: #{err}")
53
+ return
54
+ end
55
+
56
+ buf = ctx.editor.add_virtual_buffer(
57
+ kind: :git_branch,
58
+ name: "[Git Branch]",
59
+ lines: lines,
60
+ readonly: true,
61
+ modifiable: false
62
+ )
63
+ buf.options["git_repo_root"] = root
64
+ ctx.editor.switch_to_buffer(buf.id)
65
+ bind_git_buffer_keys(ctx.editor, buf.id)
66
+ ctx.editor.echo("[Git Branch]")
67
+ end
68
+
69
+ def git_branch_checkout(ctx, **)
70
+ buf = ctx.buffer
71
+ unless buf.kind == :git_branch
72
+ ctx.editor.echo_error("Not a git branch buffer")
73
+ return
74
+ end
75
+
76
+ line = buf.line_at(ctx.window.cursor_y)
77
+ branch = Branch.parse_branch_name(line)
78
+ unless branch
79
+ ctx.editor.echo_error("No branch on this line")
80
+ return
81
+ end
82
+
83
+ root = buf.options["git_repo_root"]
84
+ _out, err, status = Open3.capture3("git", "checkout", branch, chdir: root)
85
+ unless status.success?
86
+ ctx.editor.echo_error("git checkout: #{err.strip}")
87
+ return
88
+ end
89
+
90
+ # Refresh the branch list
91
+ ctx.editor.delete_buffer(buf.id)
92
+ ctx.editor.echo("Switched to branch '#{branch}'")
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Commit
8
+ module_function
9
+
10
+ # Build the initial content for the commit message buffer.
11
+ # Returns [lines, root, error_message].
12
+ def prepare(file_path)
13
+ root, err = Git.repo_root(file_path)
14
+ return [nil, nil, err] unless root
15
+
16
+ status_out, err, status = Open3.capture3("git", "status", chdir: root)
17
+ unless status.success?
18
+ return [nil, nil, err.strip]
19
+ end
20
+
21
+ lines = [""]
22
+ lines << "# Enter commit message above. Lines starting with '#' are ignored."
23
+ lines << "# Close with :wq to commit, :q! to cancel."
24
+ lines << "#"
25
+ status_out.each_line(chomp: true) { |l| lines << "# #{l}" }
26
+ [lines, root, nil]
27
+ end
28
+
29
+ # Extract commit message from buffer lines (skip # comment lines and trim).
30
+ def extract_message(lines)
31
+ msg_lines = lines.reject { |l| l.start_with?("#") }
32
+ # Strip trailing blank lines
33
+ msg_lines.pop while msg_lines.last&.empty?
34
+ msg_lines.join("\n")
35
+ end
36
+
37
+ # Execute git commit with the given message.
38
+ # Returns [success, output_or_error].
39
+ def execute(root, message)
40
+ out, err, status = Open3.capture3("git", "commit", "-m", message, chdir: root)
41
+ if status.success?
42
+ [true, out.strip]
43
+ else
44
+ [false, err.strip]
45
+ end
46
+ end
47
+
48
+ # Command handler methods
49
+ module HandlerMethods
50
+ def git_commit(ctx, **)
51
+ file_path = git_resolve_path(ctx)
52
+ unless file_path
53
+ ctx.editor.echo_error("No file or directory to resolve git repo")
54
+ return
55
+ end
56
+
57
+ lines, root, err = Commit.prepare(file_path)
58
+ unless lines
59
+ ctx.editor.echo_error("git commit: #{err}")
60
+ return
61
+ end
62
+
63
+ buf = ctx.editor.add_virtual_buffer(
64
+ kind: :git_commit,
65
+ name: "[Commit Message]",
66
+ lines: lines,
67
+ readonly: false,
68
+ modifiable: true
69
+ )
70
+ buf.options["git_repo_root"] = root
71
+ ctx.editor.switch_to_buffer(buf.id)
72
+ ctx.editor.enter_insert_mode
73
+ ctx.editor.echo("[Commit Message] :wq to commit, :q! to cancel")
74
+ end
75
+
76
+ def git_commit_execute(ctx, **)
77
+ buf = ctx.buffer
78
+ unless buf.kind == :git_commit
79
+ ctx.editor.echo_error("Not a git commit buffer")
80
+ return
81
+ end
82
+
83
+ message = Commit.extract_message(buf.lines)
84
+ if message.empty?
85
+ ctx.editor.echo_error("Empty commit message, aborting")
86
+ return
87
+ end
88
+
89
+ root = buf.options["git_repo_root"]
90
+ success, output = Commit.execute(root, message)
91
+ ctx.editor.delete_buffer(buf.id)
92
+
93
+ if success
94
+ ctx.editor.echo(output)
95
+ else
96
+ ctx.editor.echo_error("git commit: #{output}")
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end