ruvim 0.3.0 → 0.6.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +68 -7
  3. data/README.md +30 -7
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +18 -1
  10. data/docs/command.md +156 -10
  11. data/docs/config.md +10 -2
  12. data/docs/done.md +23 -0
  13. data/docs/spec.md +162 -25
  14. data/docs/todo.md +9 -0
  15. data/docs/tutorial.md +33 -1
  16. data/docs/vim_diff.md +31 -8
  17. data/ext/ruvim/extconf.rb +5 -0
  18. data/ext/ruvim/ruvim_ext.c +519 -0
  19. data/lib/ruvim/app.rb +246 -2525
  20. data/lib/ruvim/browser.rb +104 -0
  21. data/lib/ruvim/buffer.rb +43 -20
  22. data/lib/ruvim/cli.rb +6 -0
  23. data/lib/ruvim/command_invocation.rb +2 -2
  24. data/lib/ruvim/completion_manager.rb +708 -0
  25. data/lib/ruvim/dispatcher.rb +14 -8
  26. data/lib/ruvim/display_width.rb +91 -45
  27. data/lib/ruvim/editor.rb +74 -80
  28. data/lib/ruvim/ex_command_registry.rb +3 -1
  29. data/lib/ruvim/file_watcher.rb +243 -0
  30. data/lib/ruvim/gh/link.rb +207 -0
  31. data/lib/ruvim/git/blame.rb +255 -0
  32. data/lib/ruvim/git/branch.rb +112 -0
  33. data/lib/ruvim/git/commit.rb +102 -0
  34. data/lib/ruvim/git/diff.rb +129 -0
  35. data/lib/ruvim/git/grep.rb +107 -0
  36. data/lib/ruvim/git/handler.rb +125 -0
  37. data/lib/ruvim/git/log.rb +41 -0
  38. data/lib/ruvim/git/status.rb +103 -0
  39. data/lib/ruvim/global_commands.rb +351 -77
  40. data/lib/ruvim/highlighter.rb +4 -11
  41. data/lib/ruvim/input.rb +1 -0
  42. data/lib/ruvim/key_handler.rb +1510 -0
  43. data/lib/ruvim/keymap_manager.rb +7 -7
  44. data/lib/ruvim/lang/base.rb +5 -0
  45. data/lib/ruvim/lang/c.rb +116 -0
  46. data/lib/ruvim/lang/cpp.rb +107 -0
  47. data/lib/ruvim/lang/csv.rb +4 -1
  48. data/lib/ruvim/lang/diff.rb +43 -0
  49. data/lib/ruvim/lang/dockerfile.rb +36 -0
  50. data/lib/ruvim/lang/elixir.rb +85 -0
  51. data/lib/ruvim/lang/erb.rb +30 -0
  52. data/lib/ruvim/lang/go.rb +83 -0
  53. data/lib/ruvim/lang/html.rb +34 -0
  54. data/lib/ruvim/lang/javascript.rb +83 -0
  55. data/lib/ruvim/lang/json.rb +40 -0
  56. data/lib/ruvim/lang/lua.rb +76 -0
  57. data/lib/ruvim/lang/makefile.rb +36 -0
  58. data/lib/ruvim/lang/markdown.rb +3 -4
  59. data/lib/ruvim/lang/ocaml.rb +77 -0
  60. data/lib/ruvim/lang/perl.rb +91 -0
  61. data/lib/ruvim/lang/python.rb +85 -0
  62. data/lib/ruvim/lang/registry.rb +102 -0
  63. data/lib/ruvim/lang/ruby.rb +7 -0
  64. data/lib/ruvim/lang/rust.rb +95 -0
  65. data/lib/ruvim/lang/scheme.rb +5 -0
  66. data/lib/ruvim/lang/sh.rb +76 -0
  67. data/lib/ruvim/lang/sql.rb +52 -0
  68. data/lib/ruvim/lang/toml.rb +36 -0
  69. data/lib/ruvim/lang/tsv.rb +4 -1
  70. data/lib/ruvim/lang/typescript.rb +53 -0
  71. data/lib/ruvim/lang/yaml.rb +62 -0
  72. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  73. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  74. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  75. data/lib/ruvim/rich_view.rb +30 -7
  76. data/lib/ruvim/screen.rb +135 -84
  77. data/lib/ruvim/stream/file_load.rb +85 -0
  78. data/lib/ruvim/stream/follow.rb +40 -0
  79. data/lib/ruvim/stream/git.rb +43 -0
  80. data/lib/ruvim/stream/run.rb +74 -0
  81. data/lib/ruvim/stream/stdin.rb +55 -0
  82. data/lib/ruvim/stream.rb +35 -0
  83. data/lib/ruvim/stream_mixer.rb +394 -0
  84. data/lib/ruvim/terminal.rb +18 -4
  85. data/lib/ruvim/text_metrics.rb +84 -65
  86. data/lib/ruvim/version.rb +1 -1
  87. data/lib/ruvim/window.rb +5 -5
  88. data/lib/ruvim.rb +31 -4
  89. data/test/app_command_test.rb +382 -0
  90. data/test/app_completion_test.rb +65 -16
  91. data/test/app_dot_repeat_test.rb +27 -3
  92. data/test/app_ex_command_test.rb +154 -0
  93. data/test/app_motion_test.rb +13 -12
  94. data/test/app_register_test.rb +2 -1
  95. data/test/app_scenario_test.rb +182 -8
  96. data/test/app_startup_test.rb +70 -27
  97. data/test/app_text_object_test.rb +2 -1
  98. data/test/app_unicode_behavior_test.rb +3 -2
  99. data/test/browser_test.rb +88 -0
  100. data/test/buffer_test.rb +24 -0
  101. data/test/cli_test.rb +77 -0
  102. data/test/clipboard_test.rb +67 -0
  103. data/test/command_invocation_test.rb +33 -0
  104. data/test/command_line_test.rb +118 -0
  105. data/test/config_dsl_test.rb +134 -0
  106. data/test/dispatcher_test.rb +74 -4
  107. data/test/display_width_test.rb +41 -0
  108. data/test/ex_command_registry_test.rb +106 -0
  109. data/test/file_watcher_test.rb +197 -0
  110. data/test/follow_test.rb +198 -0
  111. data/test/gh_link_test.rb +141 -0
  112. data/test/git_blame_test.rb +792 -0
  113. data/test/git_grep_test.rb +64 -0
  114. data/test/highlighter_test.rb +169 -0
  115. data/test/indent_test.rb +223 -0
  116. data/test/input_screen_integration_test.rb +1 -1
  117. data/test/keyword_chars_test.rb +85 -0
  118. data/test/lang_test.rb +634 -0
  119. data/test/markdown_renderer_test.rb +5 -5
  120. data/test/on_save_hook_test.rb +12 -8
  121. data/test/render_snapshot_test.rb +78 -0
  122. data/test/rich_view_test.rb +279 -23
  123. data/test/run_command_test.rb +307 -0
  124. data/test/screen_test.rb +68 -5
  125. data/test/search_option_test.rb +19 -0
  126. data/test/stream_test.rb +165 -0
  127. data/test/test_helper.rb +9 -0
  128. data/test/window_test.rb +59 -0
  129. metadata +68 -2
@@ -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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Gh
7
+ module Link
8
+ module_function
9
+
10
+ # Parse a git remote URL and return the GitHub HTTPS base URL.
11
+ # Returns nil if the remote is not a GitHub URL.
12
+ def github_url_from_remote(remote_url)
13
+ url = remote_url.to_s.strip
14
+ return nil if url.empty?
15
+
16
+ case url
17
+ when %r{\Agit@github\.com:(.+?)(?:\.git)?\z}
18
+ "https://github.com/#{$1}"
19
+ when %r{\Ahttps://github\.com/(.+?)(?:\.git)?\z}
20
+ "https://github.com/#{$1}"
21
+ end
22
+ end
23
+
24
+ # Build a GitHub blob URL.
25
+ def build_url(base_url, ref, relative_path, line_start, line_end = nil)
26
+ fragment = if line_end && line_end != line_start
27
+ "#L#{line_start}-L#{line_end}"
28
+ else
29
+ "#L#{line_start}"
30
+ end
31
+ "#{base_url}/blob/#{ref}/#{relative_path}#{fragment}"
32
+ end
33
+
34
+ # Generate OSC 52 escape sequence for clipboard copy.
35
+ def osc52_copy_sequence(text)
36
+ encoded = [text].pack("m0")
37
+ "\e]52;c;#{encoded}\a"
38
+ end
39
+
40
+ # Find a GitHub remote. If remote_name is given, use that specific remote.
41
+ # Otherwise, scan all remotes (preferring "origin", then "upstream", then others).
42
+ # Returns [remote_name, base_url] or [nil, nil].
43
+ def find_github_remote(root, remote_name = nil)
44
+ if remote_name
45
+ url, _, status = Open3.capture3("git", "remote", "get-url", remote_name, chdir: root)
46
+ return [nil, nil] unless status.success?
47
+
48
+ base = github_url_from_remote(url.strip)
49
+ return base ? [remote_name, base] : [nil, nil]
50
+ end
51
+
52
+ remotes_out, _, status = Open3.capture3("git", "remote", chdir: root)
53
+ return [nil, nil] unless status.success?
54
+
55
+ remotes = remotes_out.lines(chomp: true)
56
+ # Prefer origin, then upstream, then others
57
+ ordered = []
58
+ ordered << "origin" if remotes.include?("origin")
59
+ ordered << "upstream" if remotes.include?("upstream")
60
+ remotes.each { |r| ordered << r unless ordered.include?(r) }
61
+
62
+ ordered.each do |name|
63
+ url, _, st = Open3.capture3("git", "remote", "get-url", name, chdir: root)
64
+ next unless st.success?
65
+
66
+ base = github_url_from_remote(url.strip)
67
+ return [name, base] if base
68
+ end
69
+
70
+ [nil, nil]
71
+ end
72
+
73
+ # Check if a file differs from the remote tracking branch.
74
+ def file_differs_from_remote?(root, remote_name, branch, file_path)
75
+ remote_ref = "#{remote_name}/#{branch}"
76
+ diff_out, _, status = Open3.capture3("git", "diff", remote_ref, "--", file_path, chdir: root)
77
+ # If the remote ref doesn't exist or diff fails, consider it as differing
78
+ return true unless status.success?
79
+
80
+ !diff_out.empty?
81
+ end
82
+
83
+ # Resolve GitHub link for a file path at given line(s).
84
+ # Returns [url, warning, error_message].
85
+ def resolve(file_path, line_start, line_end = nil, remote_name: nil)
86
+ root, err = RuVim::Git.repo_root(file_path)
87
+ return [nil, nil, err] unless root
88
+
89
+ found_remote, base_url = find_github_remote(root, remote_name)
90
+ unless base_url
91
+ msg = remote_name ? "Remote '#{remote_name}' is not a GitHub remote" : "No GitHub remote found"
92
+ return [nil, nil, msg]
93
+ end
94
+
95
+ branch, _, status = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: root)
96
+ unless status.success?
97
+ return [nil, nil, "Cannot determine branch"]
98
+ end
99
+ branch = branch.strip
100
+
101
+ relative_path = file_path.sub(%r{\A#{Regexp.escape(root)}/?}, "")
102
+ url = build_url(base_url, branch, relative_path, line_start, line_end)
103
+
104
+ warning = nil
105
+ if file_differs_from_remote?(root, found_remote, branch, file_path)
106
+ warning = "(remote may differ)"
107
+ end
108
+
109
+ [url, warning, nil]
110
+ end
111
+
112
+ # Build a GitHub PR search URL for a branch.
113
+ def pr_search_url(base_url, branch)
114
+ "#{base_url}/pulls?q=head:#{branch}"
115
+ end
116
+
117
+ # Resolve GitHub PR URL for the current repo.
118
+ # Returns [url, error_message].
119
+ def resolve_pr(file_path)
120
+ root, err = RuVim::Git.repo_root(file_path)
121
+ return [nil, err] unless root
122
+
123
+ _, base_url = find_github_remote(root)
124
+ return [nil, "No GitHub remote found"] unless base_url
125
+
126
+ branch, _, status = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD", chdir: root)
127
+ return [nil, "Cannot determine branch"] unless status.success?
128
+
129
+ [pr_search_url(base_url, branch.strip), nil]
130
+ end
131
+
132
+ module HandlerMethods
133
+ def gh_link(ctx, argv: [], kwargs: {}, **)
134
+ url, warning = gh_resolve_url(ctx, argv: argv, kwargs: kwargs, command: "gh link")
135
+ return unless url
136
+
137
+ # Copy to clipboard via OSC 52
138
+ $stdout.write(Link.osc52_copy_sequence(url))
139
+ $stdout.flush
140
+
141
+ msg = warning ? "#{url} #{warning}" : url
142
+ ctx.editor.echo(msg)
143
+ end
144
+
145
+ def gh_browse(ctx, argv: [], kwargs: {}, **)
146
+ url, warning = gh_resolve_url(ctx, argv: argv, kwargs: kwargs, command: "gh browse")
147
+ return unless url
148
+
149
+ unless Browser.open_url(url)
150
+ ctx.editor.echo_error("gh browse: could not open browser")
151
+ return
152
+ end
153
+
154
+ msg = warning ? "Opened #{url} #{warning}" : "Opened #{url}"
155
+ ctx.editor.echo(msg)
156
+ end
157
+
158
+ def gh_pr(ctx, **)
159
+ path = ctx.buffer.path || Dir.pwd
160
+ url, err = Link.resolve_pr(path)
161
+ unless url
162
+ ctx.editor.echo_error("gh pr: #{err}")
163
+ return
164
+ end
165
+
166
+ unless Browser.open_url(url)
167
+ ctx.editor.echo_error("gh pr: could not open browser")
168
+ return
169
+ end
170
+
171
+ ctx.editor.echo("Opened #{url}")
172
+ end
173
+
174
+ private
175
+
176
+ def gh_resolve_url(ctx, argv:, kwargs:, command:)
177
+ path = ctx.buffer.path
178
+ unless path && File.exist?(path)
179
+ ctx.editor.echo_error("Buffer has no file path")
180
+ return nil
181
+ end
182
+
183
+ line_start = kwargs[:range_start]
184
+ line_end = kwargs[:range_end]
185
+
186
+ if line_start
187
+ line_start += 1
188
+ line_end += 1 if line_end
189
+ else
190
+ line_start = ctx.window.cursor_y + 1
191
+ line_end = nil
192
+ end
193
+
194
+ remote_name = argv.first
195
+
196
+ url, warning, err = Link.resolve(path, line_start, line_end, remote_name: remote_name)
197
+ unless url
198
+ ctx.editor.echo_error("#{command}: #{err}")
199
+ return nil
200
+ end
201
+
202
+ [url, warning]
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,255 @@
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 code lines and gutter labels.
51
+ # Returns [lines, labels] where lines are code-only and labels are metadata strings.
52
+ def format_lines(entries)
53
+ max_author = entries.map { |e| e[:author].to_s.length }.max || 0
54
+ max_author = [max_author, 20].min
55
+
56
+ lines = []
57
+ labels = []
58
+ entries.each do |e|
59
+ author = (e[:author] || "").ljust(max_author)[0, max_author]
60
+ labels << "#{e[:short_hash]} #{author} #{e[:date]} "
61
+ lines << e[:text].to_s
62
+ end
63
+ [lines, labels]
64
+ end
65
+
66
+ # Run git blame for a file at a given revision.
67
+ # Returns [entries, error_message].
68
+ def run(file_path, rev: nil)
69
+ dir = File.dirname(file_path)
70
+ basename = File.basename(file_path)
71
+
72
+ cmd = ["git", "blame", "--porcelain"]
73
+ if rev
74
+ cmd << rev << "--" << basename
75
+ else
76
+ cmd << "--" << basename
77
+ end
78
+
79
+ out, err, status = Open3.capture3(*cmd, chdir: dir)
80
+ unless status.success?
81
+ return [nil, err.strip]
82
+ end
83
+
84
+ entries = parse_porcelain(out)
85
+ [entries, nil]
86
+ end
87
+
88
+ # Run git show for a commit.
89
+ # Returns [lines, error_message].
90
+ def show_commit(file_path, commit_hash)
91
+ dir = File.dirname(file_path)
92
+ out, err, status = Open3.capture3("git", "show", commit_hash, chdir: dir)
93
+ unless status.success?
94
+ return [nil, err.strip]
95
+ end
96
+ [out.lines(chomp: true), nil]
97
+ end
98
+
99
+ # Command handler methods (included via Git::Handler)
100
+ module HandlerMethods
101
+ def git_blame(ctx, **)
102
+ source_buf = ctx.buffer
103
+ unless source_buf.path && File.exist?(source_buf.path)
104
+ ctx.editor.echo_error("No file to blame")
105
+ return
106
+ end
107
+
108
+ entries, err = Blame.run(source_buf.path)
109
+ unless entries
110
+ ctx.editor.echo_error("git blame: #{err}")
111
+ return
112
+ end
113
+
114
+ lines, labels = Blame.format_lines(entries)
115
+ cursor_y = ctx.window.cursor_y
116
+ source_ft = source_buf.options["filetype"]
117
+
118
+ blame_buf = ctx.editor.add_virtual_buffer(
119
+ kind: :blame,
120
+ name: "[Blame] #{File.basename(source_buf.path)}",
121
+ lines: lines,
122
+ filetype: source_ft,
123
+ readonly: true,
124
+ modifiable: false
125
+ )
126
+ blame_buf.options["blame_entries"] = entries
127
+ blame_buf.options["blame_source_path"] = source_buf.path
128
+ blame_buf.options["blame_history"] = []
129
+ blame_buf.options["gutter_labels"] = labels
130
+
131
+ ctx.editor.switch_to_buffer(blame_buf.id)
132
+ ctx.window.cursor_y = [cursor_y, lines.length - 1].min
133
+
134
+ bind_git_buffer_keys(ctx.editor, blame_buf.id)
135
+ bind_blame_keys(ctx.editor, blame_buf.id)
136
+ ctx.editor.echo("[Blame] #{File.basename(source_buf.path)}")
137
+ end
138
+
139
+ def git_blame_prev(ctx, **)
140
+ buf = ctx.buffer
141
+ unless buf.kind == :blame
142
+ ctx.editor.echo_error("Not a blame buffer")
143
+ return
144
+ end
145
+
146
+ entries = buf.options["blame_entries"]
147
+ source_path = buf.options["blame_source_path"]
148
+ history = buf.options["blame_history"]
149
+ cursor_y = ctx.window.cursor_y
150
+ entry = entries[cursor_y]
151
+
152
+ unless entry
153
+ ctx.editor.echo_error("No blame entry on this line")
154
+ return
155
+ end
156
+
157
+ commit_hash = entry[:hash]
158
+ if commit_hash.start_with?("0000000")
159
+ ctx.editor.echo_error("Uncommitted changes — cannot go further back")
160
+ return
161
+ end
162
+
163
+ new_entries, err = Blame.run(source_path, rev: "#{commit_hash}^")
164
+ unless new_entries
165
+ ctx.editor.echo_error("git blame: #{err}")
166
+ return
167
+ end
168
+
169
+ history.push({ entries: entries, cursor_y: cursor_y })
170
+
171
+ new_lines, new_labels = Blame.format_lines(new_entries)
172
+ buf.instance_variable_set(:@lines, new_lines)
173
+ buf.options["blame_entries"] = new_entries
174
+ buf.options["gutter_labels"] = new_labels
175
+ ctx.window.cursor_y = [cursor_y, new_lines.length - 1].min
176
+ ctx.window.cursor_x = 0
177
+ ctx.editor.echo("[Blame] #{commit_hash[0, 8]}^")
178
+ end
179
+
180
+ def git_blame_back(ctx, **)
181
+ buf = ctx.buffer
182
+ unless buf.kind == :blame
183
+ ctx.editor.echo_error("Not a blame buffer")
184
+ return
185
+ end
186
+
187
+ history = buf.options["blame_history"]
188
+ if history.nil? || history.empty?
189
+ ctx.editor.echo_error("No blame history to go back to")
190
+ return
191
+ end
192
+
193
+ state = history.pop
194
+ lines, labels = Blame.format_lines(state[:entries])
195
+ buf.instance_variable_set(:@lines, lines)
196
+ buf.options["blame_entries"] = state[:entries]
197
+ buf.options["gutter_labels"] = labels
198
+ ctx.window.cursor_y = [state[:cursor_y], lines.length - 1].min
199
+ ctx.window.cursor_x = 0
200
+ ctx.editor.echo("[Blame] restored")
201
+ end
202
+
203
+ def git_blame_commit(ctx, **)
204
+ buf = ctx.buffer
205
+ unless buf.kind == :blame
206
+ ctx.editor.echo_error("Not a blame buffer")
207
+ return
208
+ end
209
+
210
+ entries = buf.options["blame_entries"]
211
+ source_path = buf.options["blame_source_path"]
212
+ entry = entries[ctx.window.cursor_y]
213
+
214
+ unless entry
215
+ ctx.editor.echo_error("No blame entry on this line")
216
+ return
217
+ end
218
+
219
+ commit_hash = entry[:hash]
220
+ if commit_hash.start_with?("0000000")
221
+ ctx.editor.echo_error("Uncommitted changes — no commit to show")
222
+ return
223
+ end
224
+
225
+ lines, err = Blame.show_commit(source_path, commit_hash)
226
+ unless lines
227
+ ctx.editor.echo_error("git show: #{err}")
228
+ return
229
+ end
230
+
231
+ show_buf = ctx.editor.add_virtual_buffer(
232
+ kind: :git_show,
233
+ name: "[Commit] #{commit_hash[0, 8]}",
234
+ lines: lines,
235
+ filetype: "diff",
236
+ readonly: true,
237
+ modifiable: false
238
+ )
239
+ ctx.editor.switch_to_buffer(show_buf.id)
240
+ bind_git_buffer_keys(ctx.editor, show_buf.id)
241
+ ctx.editor.echo("[Commit] #{commit_hash[0, 8]}")
242
+ end
243
+
244
+ private
245
+
246
+ def bind_blame_keys(editor, buffer_id)
247
+ km = editor.keymap_manager
248
+ km.bind_buffer(buffer_id, "p", "git.blame.prev")
249
+ km.bind_buffer(buffer_id, "P", "git.blame.back")
250
+ km.bind_buffer(buffer_id, "c", "git.blame.commit")
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end