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.
- checksums.yaml +4 -4
- data/AGENTS.md +68 -7
- data/README.md +30 -7
- data/Rakefile +7 -0
- data/benchmark/cext_compare.rb +165 -0
- data/benchmark/chunked_load.rb +256 -0
- data/benchmark/file_load.rb +140 -0
- data/benchmark/hotspots.rb +178 -0
- data/docs/binding.md +18 -1
- data/docs/command.md +156 -10
- data/docs/config.md +10 -2
- data/docs/done.md +23 -0
- data/docs/spec.md +162 -25
- data/docs/todo.md +9 -0
- data/docs/tutorial.md +33 -1
- data/docs/vim_diff.md +31 -8
- data/ext/ruvim/extconf.rb +5 -0
- data/ext/ruvim/ruvim_ext.c +519 -0
- data/lib/ruvim/app.rb +246 -2525
- data/lib/ruvim/browser.rb +104 -0
- data/lib/ruvim/buffer.rb +43 -20
- data/lib/ruvim/cli.rb +6 -0
- data/lib/ruvim/command_invocation.rb +2 -2
- data/lib/ruvim/completion_manager.rb +708 -0
- data/lib/ruvim/dispatcher.rb +14 -8
- data/lib/ruvim/display_width.rb +91 -45
- data/lib/ruvim/editor.rb +74 -80
- data/lib/ruvim/ex_command_registry.rb +3 -1
- data/lib/ruvim/file_watcher.rb +243 -0
- data/lib/ruvim/gh/link.rb +207 -0
- data/lib/ruvim/git/blame.rb +255 -0
- data/lib/ruvim/git/branch.rb +112 -0
- data/lib/ruvim/git/commit.rb +102 -0
- data/lib/ruvim/git/diff.rb +129 -0
- data/lib/ruvim/git/grep.rb +107 -0
- data/lib/ruvim/git/handler.rb +125 -0
- data/lib/ruvim/git/log.rb +41 -0
- data/lib/ruvim/git/status.rb +103 -0
- data/lib/ruvim/global_commands.rb +351 -77
- data/lib/ruvim/highlighter.rb +4 -11
- data/lib/ruvim/input.rb +1 -0
- data/lib/ruvim/key_handler.rb +1510 -0
- data/lib/ruvim/keymap_manager.rb +7 -7
- data/lib/ruvim/lang/base.rb +5 -0
- data/lib/ruvim/lang/c.rb +116 -0
- data/lib/ruvim/lang/cpp.rb +107 -0
- data/lib/ruvim/lang/csv.rb +4 -1
- data/lib/ruvim/lang/diff.rb +43 -0
- data/lib/ruvim/lang/dockerfile.rb +36 -0
- data/lib/ruvim/lang/elixir.rb +85 -0
- data/lib/ruvim/lang/erb.rb +30 -0
- data/lib/ruvim/lang/go.rb +83 -0
- data/lib/ruvim/lang/html.rb +34 -0
- data/lib/ruvim/lang/javascript.rb +83 -0
- data/lib/ruvim/lang/json.rb +40 -0
- data/lib/ruvim/lang/lua.rb +76 -0
- data/lib/ruvim/lang/makefile.rb +36 -0
- data/lib/ruvim/lang/markdown.rb +3 -4
- data/lib/ruvim/lang/ocaml.rb +77 -0
- data/lib/ruvim/lang/perl.rb +91 -0
- data/lib/ruvim/lang/python.rb +85 -0
- data/lib/ruvim/lang/registry.rb +102 -0
- data/lib/ruvim/lang/ruby.rb +7 -0
- data/lib/ruvim/lang/rust.rb +95 -0
- data/lib/ruvim/lang/scheme.rb +5 -0
- data/lib/ruvim/lang/sh.rb +76 -0
- data/lib/ruvim/lang/sql.rb +52 -0
- data/lib/ruvim/lang/toml.rb +36 -0
- data/lib/ruvim/lang/tsv.rb +4 -1
- data/lib/ruvim/lang/typescript.rb +53 -0
- data/lib/ruvim/lang/yaml.rb +62 -0
- data/lib/ruvim/rich_view/json_renderer.rb +131 -0
- data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
- data/lib/ruvim/rich_view/table_renderer.rb +3 -3
- data/lib/ruvim/rich_view.rb +30 -7
- data/lib/ruvim/screen.rb +135 -84
- data/lib/ruvim/stream/file_load.rb +85 -0
- data/lib/ruvim/stream/follow.rb +40 -0
- data/lib/ruvim/stream/git.rb +43 -0
- data/lib/ruvim/stream/run.rb +74 -0
- data/lib/ruvim/stream/stdin.rb +55 -0
- data/lib/ruvim/stream.rb +35 -0
- data/lib/ruvim/stream_mixer.rb +394 -0
- data/lib/ruvim/terminal.rb +18 -4
- data/lib/ruvim/text_metrics.rb +84 -65
- data/lib/ruvim/version.rb +1 -1
- data/lib/ruvim/window.rb +5 -5
- data/lib/ruvim.rb +31 -4
- data/test/app_command_test.rb +382 -0
- data/test/app_completion_test.rb +65 -16
- data/test/app_dot_repeat_test.rb +27 -3
- data/test/app_ex_command_test.rb +154 -0
- data/test/app_motion_test.rb +13 -12
- data/test/app_register_test.rb +2 -1
- data/test/app_scenario_test.rb +182 -8
- data/test/app_startup_test.rb +70 -27
- data/test/app_text_object_test.rb +2 -1
- data/test/app_unicode_behavior_test.rb +3 -2
- data/test/browser_test.rb +88 -0
- data/test/buffer_test.rb +24 -0
- data/test/cli_test.rb +77 -0
- data/test/clipboard_test.rb +67 -0
- data/test/command_invocation_test.rb +33 -0
- data/test/command_line_test.rb +118 -0
- data/test/config_dsl_test.rb +134 -0
- data/test/dispatcher_test.rb +74 -4
- data/test/display_width_test.rb +41 -0
- data/test/ex_command_registry_test.rb +106 -0
- data/test/file_watcher_test.rb +197 -0
- data/test/follow_test.rb +198 -0
- data/test/gh_link_test.rb +141 -0
- data/test/git_blame_test.rb +792 -0
- data/test/git_grep_test.rb +64 -0
- data/test/highlighter_test.rb +169 -0
- data/test/indent_test.rb +223 -0
- data/test/input_screen_integration_test.rb +1 -1
- data/test/keyword_chars_test.rb +85 -0
- data/test/lang_test.rb +634 -0
- data/test/markdown_renderer_test.rb +5 -5
- data/test/on_save_hook_test.rb +12 -8
- data/test/render_snapshot_test.rb +78 -0
- data/test/rich_view_test.rb +279 -23
- data/test/run_command_test.rb +307 -0
- data/test/screen_test.rb +68 -5
- data/test/search_option_test.rb +19 -0
- data/test/stream_test.rb +165 -0
- data/test/test_helper.rb +9 -0
- data/test/window_test.rb +59 -0
- 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
|