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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +4 -0
- data/AGENTS.md +96 -0
- data/CLAUDE.md +1 -0
- data/README.md +15 -1
- data/docs/binding.md +39 -0
- data/docs/command.md +163 -4
- data/docs/config.md +12 -4
- data/docs/done.md +21 -0
- data/docs/spec.md +214 -18
- data/docs/todo.md +1 -5
- data/docs/tutorial.md +24 -0
- data/docs/vim_diff.md +105 -173
- data/lib/ruvim/app.rb +1165 -70
- data/lib/ruvim/buffer.rb +47 -1
- data/lib/ruvim/cli.rb +18 -3
- data/lib/ruvim/clipboard.rb +2 -0
- data/lib/ruvim/command_invocation.rb +3 -1
- data/lib/ruvim/command_line.rb +2 -0
- data/lib/ruvim/command_registry.rb +2 -0
- data/lib/ruvim/config_dsl.rb +2 -0
- data/lib/ruvim/config_loader.rb +2 -0
- data/lib/ruvim/context.rb +2 -0
- data/lib/ruvim/dispatcher.rb +143 -13
- data/lib/ruvim/display_width.rb +3 -0
- data/lib/ruvim/editor.rb +466 -71
- data/lib/ruvim/ex_command_registry.rb +2 -0
- data/lib/ruvim/file_watcher.rb +243 -0
- data/lib/ruvim/git/blame.rb +245 -0
- data/lib/ruvim/git/branch.rb +97 -0
- data/lib/ruvim/git/commit.rb +102 -0
- data/lib/ruvim/git/diff.rb +129 -0
- data/lib/ruvim/git/handler.rb +84 -0
- data/lib/ruvim/git/log.rb +41 -0
- data/lib/ruvim/git/status.rb +103 -0
- data/lib/ruvim/global_commands.rb +1066 -105
- data/lib/ruvim/highlighter.rb +19 -22
- data/lib/ruvim/input.rb +40 -28
- data/lib/ruvim/keymap_manager.rb +83 -0
- data/lib/ruvim/keyword_chars.rb +2 -0
- data/lib/ruvim/lang/base.rb +25 -0
- data/lib/ruvim/lang/csv.rb +18 -0
- data/lib/ruvim/lang/diff.rb +41 -0
- data/lib/ruvim/lang/json.rb +52 -0
- data/lib/ruvim/lang/markdown.rb +170 -0
- data/lib/ruvim/lang/ruby.rb +236 -0
- data/lib/ruvim/lang/scheme.rb +44 -0
- data/lib/ruvim/lang/tsv.rb +19 -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/markdown_renderer.rb +248 -0
- data/lib/ruvim/rich_view/table_renderer.rb +176 -0
- data/lib/ruvim/rich_view.rb +109 -0
- data/lib/ruvim/screen.rb +503 -109
- data/lib/ruvim/terminal.rb +18 -1
- data/lib/ruvim/text_metrics.rb +2 -0
- data/lib/ruvim/version.rb +1 -1
- data/lib/ruvim/window.rb +2 -0
- data/lib/ruvim.rb +24 -0
- data/test/app_completion_test.rb +98 -0
- data/test/app_dot_repeat_test.rb +13 -0
- data/test/app_motion_test.rb +13 -0
- data/test/app_scenario_test.rb +898 -1
- data/test/app_startup_test.rb +187 -0
- data/test/arglist_test.rb +113 -0
- data/test/buffer_test.rb +49 -30
- data/test/cli_test.rb +14 -0
- data/test/clipboard_test.rb +67 -0
- data/test/command_line_test.rb +118 -0
- data/test/config_dsl_test.rb +87 -0
- data/test/dispatcher_test.rb +322 -0
- data/test/display_width_test.rb +41 -0
- data/test/editor_register_test.rb +23 -0
- data/test/file_watcher_test.rb +197 -0
- data/test/follow_test.rb +199 -0
- data/test/git_blame_test.rb +713 -0
- data/test/highlighter_test.rb +165 -0
- data/test/indent_test.rb +287 -0
- data/test/input_screen_integration_test.rb +40 -2
- data/test/markdown_renderer_test.rb +279 -0
- data/test/on_save_hook_test.rb +150 -0
- data/test/rich_view_test.rb +734 -0
- data/test/screen_test.rb +304 -0
- data/test/search_option_test.rb +19 -0
- data/test/test_helper.rb +9 -0
- metadata +49 -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,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
|