ruvim 0.4.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +53 -4
  3. data/README.md +15 -6
  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 +3 -2
  10. data/docs/command.md +81 -9
  11. data/docs/done.md +23 -0
  12. data/docs/spec.md +105 -19
  13. data/docs/todo.md +9 -0
  14. data/docs/tutorial.md +9 -1
  15. data/docs/vim_diff.md +13 -0
  16. data/ext/ruvim/extconf.rb +5 -0
  17. data/ext/ruvim/ruvim_ext.c +519 -0
  18. data/lib/ruvim/app.rb +217 -2778
  19. data/lib/ruvim/browser.rb +104 -0
  20. data/lib/ruvim/buffer.rb +39 -28
  21. data/lib/ruvim/command_invocation.rb +2 -2
  22. data/lib/ruvim/completion_manager.rb +708 -0
  23. data/lib/ruvim/dispatcher.rb +14 -8
  24. data/lib/ruvim/display_width.rb +91 -45
  25. data/lib/ruvim/editor.rb +64 -81
  26. data/lib/ruvim/ex_command_registry.rb +3 -1
  27. data/lib/ruvim/gh/link.rb +207 -0
  28. data/lib/ruvim/git/blame.rb +16 -6
  29. data/lib/ruvim/git/branch.rb +20 -5
  30. data/lib/ruvim/git/grep.rb +107 -0
  31. data/lib/ruvim/git/handler.rb +42 -1
  32. data/lib/ruvim/global_commands.rb +175 -35
  33. data/lib/ruvim/highlighter.rb +4 -13
  34. data/lib/ruvim/key_handler.rb +1510 -0
  35. data/lib/ruvim/keymap_manager.rb +7 -7
  36. data/lib/ruvim/lang/base.rb +5 -0
  37. data/lib/ruvim/lang/c.rb +116 -0
  38. data/lib/ruvim/lang/cpp.rb +107 -0
  39. data/lib/ruvim/lang/csv.rb +4 -1
  40. data/lib/ruvim/lang/diff.rb +2 -0
  41. data/lib/ruvim/lang/dockerfile.rb +36 -0
  42. data/lib/ruvim/lang/elixir.rb +85 -0
  43. data/lib/ruvim/lang/erb.rb +30 -0
  44. data/lib/ruvim/lang/go.rb +83 -0
  45. data/lib/ruvim/lang/html.rb +34 -0
  46. data/lib/ruvim/lang/javascript.rb +83 -0
  47. data/lib/ruvim/lang/json.rb +6 -0
  48. data/lib/ruvim/lang/lua.rb +76 -0
  49. data/lib/ruvim/lang/makefile.rb +36 -0
  50. data/lib/ruvim/lang/markdown.rb +3 -4
  51. data/lib/ruvim/lang/ocaml.rb +77 -0
  52. data/lib/ruvim/lang/perl.rb +91 -0
  53. data/lib/ruvim/lang/python.rb +85 -0
  54. data/lib/ruvim/lang/registry.rb +102 -0
  55. data/lib/ruvim/lang/ruby.rb +7 -0
  56. data/lib/ruvim/lang/rust.rb +95 -0
  57. data/lib/ruvim/lang/scheme.rb +5 -0
  58. data/lib/ruvim/lang/sh.rb +76 -0
  59. data/lib/ruvim/lang/sql.rb +52 -0
  60. data/lib/ruvim/lang/toml.rb +36 -0
  61. data/lib/ruvim/lang/tsv.rb +4 -1
  62. data/lib/ruvim/lang/typescript.rb +53 -0
  63. data/lib/ruvim/lang/yaml.rb +62 -0
  64. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  65. data/lib/ruvim/rich_view.rb +14 -7
  66. data/lib/ruvim/screen.rb +126 -72
  67. data/lib/ruvim/stream/file_load.rb +85 -0
  68. data/lib/ruvim/stream/follow.rb +40 -0
  69. data/lib/ruvim/stream/git.rb +43 -0
  70. data/lib/ruvim/stream/run.rb +74 -0
  71. data/lib/ruvim/stream/stdin.rb +55 -0
  72. data/lib/ruvim/stream.rb +35 -0
  73. data/lib/ruvim/stream_mixer.rb +394 -0
  74. data/lib/ruvim/terminal.rb +18 -4
  75. data/lib/ruvim/text_metrics.rb +84 -65
  76. data/lib/ruvim/version.rb +1 -1
  77. data/lib/ruvim/window.rb +5 -5
  78. data/lib/ruvim.rb +23 -6
  79. data/test/app_command_test.rb +382 -0
  80. data/test/app_completion_test.rb +43 -19
  81. data/test/app_dot_repeat_test.rb +27 -3
  82. data/test/app_ex_command_test.rb +154 -0
  83. data/test/app_motion_test.rb +13 -12
  84. data/test/app_register_test.rb +2 -1
  85. data/test/app_scenario_test.rb +15 -10
  86. data/test/app_startup_test.rb +70 -27
  87. data/test/app_text_object_test.rb +2 -1
  88. data/test/app_unicode_behavior_test.rb +3 -2
  89. data/test/browser_test.rb +88 -0
  90. data/test/buffer_test.rb +24 -0
  91. data/test/cli_test.rb +63 -0
  92. data/test/command_invocation_test.rb +33 -0
  93. data/test/config_dsl_test.rb +47 -0
  94. data/test/dispatcher_test.rb +74 -4
  95. data/test/ex_command_registry_test.rb +106 -0
  96. data/test/follow_test.rb +20 -21
  97. data/test/gh_link_test.rb +141 -0
  98. data/test/git_blame_test.rb +96 -17
  99. data/test/git_grep_test.rb +64 -0
  100. data/test/highlighter_test.rb +125 -0
  101. data/test/indent_test.rb +137 -0
  102. data/test/input_screen_integration_test.rb +1 -1
  103. data/test/keyword_chars_test.rb +85 -0
  104. data/test/lang_test.rb +634 -0
  105. data/test/markdown_renderer_test.rb +5 -5
  106. data/test/on_save_hook_test.rb +12 -8
  107. data/test/render_snapshot_test.rb +78 -0
  108. data/test/rich_view_test.rb +42 -42
  109. data/test/run_command_test.rb +307 -0
  110. data/test/screen_test.rb +68 -5
  111. data/test/stream_test.rb +165 -0
  112. data/test/window_test.rb +59 -0
  113. metadata +52 -2
@@ -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
@@ -47,15 +47,20 @@ module RuVim
47
47
  entries
48
48
  end
49
49
 
50
- # Format entries into display lines for the blame buffer.
50
+ # Format entries into code lines and gutter labels.
51
+ # Returns [lines, labels] where lines are code-only and labels are metadata strings.
51
52
  def format_lines(entries)
52
53
  max_author = entries.map { |e| e[:author].to_s.length }.max || 0
53
54
  max_author = [max_author, 20].min
54
55
 
55
- entries.map do |e|
56
+ lines = []
57
+ labels = []
58
+ entries.each do |e|
56
59
  author = (e[:author] || "").ljust(max_author)[0, max_author]
57
- "#{e[:short_hash]} #{author} #{e[:date]} #{e[:text]}"
60
+ labels << "#{e[:short_hash]} #{author} #{e[:date]} "
61
+ lines << e[:text].to_s
58
62
  end
63
+ [lines, labels]
59
64
  end
60
65
 
61
66
  # Run git blame for a file at a given revision.
@@ -106,19 +111,22 @@ module RuVim
106
111
  return
107
112
  end
108
113
 
109
- lines = Blame.format_lines(entries)
114
+ lines, labels = Blame.format_lines(entries)
110
115
  cursor_y = ctx.window.cursor_y
116
+ source_ft = source_buf.options["filetype"]
111
117
 
112
118
  blame_buf = ctx.editor.add_virtual_buffer(
113
119
  kind: :blame,
114
120
  name: "[Blame] #{File.basename(source_buf.path)}",
115
121
  lines: lines,
122
+ filetype: source_ft,
116
123
  readonly: true,
117
124
  modifiable: false
118
125
  )
119
126
  blame_buf.options["blame_entries"] = entries
120
127
  blame_buf.options["blame_source_path"] = source_buf.path
121
128
  blame_buf.options["blame_history"] = []
129
+ blame_buf.options["gutter_labels"] = labels
122
130
 
123
131
  ctx.editor.switch_to_buffer(blame_buf.id)
124
132
  ctx.window.cursor_y = [cursor_y, lines.length - 1].min
@@ -160,9 +168,10 @@ module RuVim
160
168
 
161
169
  history.push({ entries: entries, cursor_y: cursor_y })
162
170
 
163
- new_lines = Blame.format_lines(new_entries)
171
+ new_lines, new_labels = Blame.format_lines(new_entries)
164
172
  buf.instance_variable_set(:@lines, new_lines)
165
173
  buf.options["blame_entries"] = new_entries
174
+ buf.options["gutter_labels"] = new_labels
166
175
  ctx.window.cursor_y = [cursor_y, new_lines.length - 1].min
167
176
  ctx.window.cursor_x = 0
168
177
  ctx.editor.echo("[Blame] #{commit_hash[0, 8]}^")
@@ -182,9 +191,10 @@ module RuVim
182
191
  end
183
192
 
184
193
  state = history.pop
185
- lines = Blame.format_lines(state[:entries])
194
+ lines, labels = Blame.format_lines(state[:entries])
186
195
  buf.instance_variable_set(:@lines, lines)
187
196
  buf.options["blame_entries"] = state[:entries]
197
+ buf.options["gutter_labels"] = labels
188
198
  ctx.window.cursor_y = [state[:cursor_y], lines.length - 1].min
189
199
  ctx.window.cursor_x = 0
190
200
  ctx.editor.echo("[Blame] restored")
@@ -80,15 +80,30 @@ module RuVim
80
80
  return
81
81
  end
82
82
 
83
- root = buf.options["git_repo_root"]
83
+ ctx.editor.enter_command_line_mode(":")
84
+ ctx.editor.command_line.replace_text("git checkout #{branch}")
85
+ end
86
+
87
+ def git_branch_execute_checkout(ctx, argv: [], **)
88
+ branch = argv.first.to_s.strip
89
+ raise RuVim::CommandError, "Usage: :git checkout <branch>" if branch.empty?
90
+
91
+ root = ctx.buffer.kind == :git_branch ? ctx.buffer.options["git_repo_root"] : nil
92
+ unless root
93
+ file_path = git_resolve_path(ctx)
94
+ root, err = Git.repo_root(file_path) if file_path
95
+ raise RuVim::CommandError, "Not in a git repository" unless root
96
+ end
97
+
84
98
  _out, err, status = Open3.capture3("git", "checkout", branch, chdir: root)
85
99
  unless status.success?
86
- ctx.editor.echo_error("git checkout: #{err.strip}")
87
- return
100
+ raise RuVim::CommandError, "git checkout: #{err.strip}"
88
101
  end
89
102
 
90
- # Refresh the branch list
91
- ctx.editor.delete_buffer(buf.id)
103
+ # Close git branch buffer if we're in one
104
+ if ctx.buffer.kind == :git_branch
105
+ ctx.editor.delete_buffer(ctx.buffer.id)
106
+ end
92
107
  ctx.editor.echo("Switched to branch '#{branch}'")
93
108
  end
94
109
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuVim
6
+ module Git
7
+ module Grep
8
+ module_function
9
+
10
+ # Run git grep -n with extra args.
11
+ # Returns [lines, root, error_message].
12
+ def run(file_path, args: [])
13
+ root, err = Git.repo_root(file_path)
14
+ return [nil, nil, err] unless root
15
+
16
+ cmd = ["git", "grep", "-n", *args]
17
+ out, err, status = Open3.capture3(*cmd, chdir: root)
18
+ # git grep exits 1 when no matches found (not an error)
19
+ unless status.success? || status.exitstatus == 1
20
+ return [nil, nil, err.strip]
21
+ end
22
+ [out.lines(chomp: true), root, nil]
23
+ end
24
+
25
+ # Parse a git grep output line (file:line:content).
26
+ # Returns [filename, line_number] or nil.
27
+ def parse_location(line)
28
+ return nil if line.empty? || line == "--"
29
+
30
+ m = line.match(/\A(.+?):(\d+):/)
31
+ return nil unless m
32
+
33
+ [m[1], m[2].to_i]
34
+ end
35
+
36
+ module HandlerMethods
37
+ def git_grep(ctx, argv: [], **)
38
+ if argv.empty?
39
+ ctx.editor.echo_error("Usage: :git grep <pattern> [<args>...]")
40
+ return
41
+ end
42
+
43
+ file_path = git_resolve_path(ctx)
44
+ unless file_path
45
+ ctx.editor.echo_error("No file or directory to resolve git repo")
46
+ return
47
+ end
48
+
49
+ lines, root, err = Grep.run(file_path, args: argv)
50
+ unless lines
51
+ ctx.editor.echo_error("git grep: #{err}")
52
+ return
53
+ end
54
+
55
+ if lines.empty?
56
+ ctx.editor.echo("No matches found")
57
+ return
58
+ end
59
+
60
+ buf = ctx.editor.add_virtual_buffer(
61
+ kind: :git_grep,
62
+ name: "[Git Grep]",
63
+ lines: lines,
64
+ readonly: true,
65
+ modifiable: false
66
+ )
67
+ buf.options["git_repo_root"] = root
68
+ ctx.editor.switch_to_buffer(buf.id)
69
+ bind_git_buffer_keys(ctx.editor, buf.id)
70
+ ctx.editor.echo("[Git Grep] #{lines.length} match(es)")
71
+ end
72
+
73
+ def git_grep_open_file(ctx, **)
74
+ buf = ctx.buffer
75
+ unless buf.kind == :git_grep
76
+ ctx.editor.echo_error("Not a git grep buffer")
77
+ return
78
+ end
79
+
80
+ line = buf.lines[ctx.window.cursor_y]
81
+ location = Grep.parse_location(line)
82
+ unless location
83
+ ctx.editor.echo_error("No file on this line")
84
+ return
85
+ end
86
+
87
+ filename, line_num = location
88
+ root = buf.options["git_repo_root"]
89
+ full_path = File.join(root, filename)
90
+ unless File.exist?(full_path)
91
+ ctx.editor.echo_error("File not found: #{filename}")
92
+ return
93
+ end
94
+
95
+ existing = ctx.editor.buffers.values.find { |b| b.path == full_path }
96
+ if existing
97
+ ctx.editor.switch_to_buffer(existing.id)
98
+ else
99
+ new_buf = ctx.editor.add_buffer_from_file(full_path)
100
+ ctx.editor.switch_to_buffer(new_buf.id)
101
+ end
102
+ ctx.editor.current_window.cursor_y = [line_num - 1, 0].max
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -27,7 +27,9 @@ module RuVim
27
27
  "diff" => :git_diff,
28
28
  "log" => :git_log,
29
29
  "branch" => :git_branch,
30
+ "checkout" => :git_branch_execute_checkout,
30
31
  "commit" => :git_commit,
32
+ "grep" => :git_grep,
31
33
  }.freeze
32
34
 
33
35
  include Blame::HandlerMethods
@@ -36,6 +38,14 @@ module RuVim
36
38
  include Log::HandlerMethods
37
39
  include Branch::HandlerMethods
38
40
  include Commit::HandlerMethods
41
+ include Grep::HandlerMethods
42
+ include Gh::Link::HandlerMethods
43
+
44
+ GH_SUBCOMMANDS = {
45
+ "link" => :gh_link,
46
+ "browse" => :gh_browse,
47
+ "pr" => :gh_pr,
48
+ }.freeze
39
49
 
40
50
  def enter_git_command_mode(ctx, **)
41
51
  ctx.editor.enter_command_line_mode(":")
@@ -44,6 +54,8 @@ module RuVim
44
54
  end
45
55
 
46
56
  def ex_git(ctx, argv: [], **)
57
+ raise RuVim::CommandError, "Restricted mode: :git is disabled" if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
58
+
47
59
  sub = argv.first.to_s.downcase
48
60
  if sub.empty?
49
61
  ctx.editor.echo("Git subcommands: #{GIT_SUBCOMMANDS.keys.join(', ')}")
@@ -52,13 +64,31 @@ module RuVim
52
64
 
53
65
  method = GIT_SUBCOMMANDS[sub]
54
66
  unless method
55
- ctx.editor.echo_error("Unknown Git subcommand: #{sub}")
67
+ run_shell_fallback(ctx, "git", argv)
56
68
  return
57
69
  end
58
70
 
59
71
  public_send(method, ctx, argv: argv[1..], kwargs: {}, bang: false, count: 1)
60
72
  end
61
73
 
74
+ def ex_gh(ctx, argv: [], kwargs: {}, **)
75
+ raise RuVim::CommandError, "Restricted mode: :gh is disabled" if ctx.editor.respond_to?(:restricted_mode?) && ctx.editor.restricted_mode?
76
+
77
+ sub = argv.first.to_s.downcase
78
+ if sub.empty?
79
+ ctx.editor.echo("GitHub subcommands: #{GH_SUBCOMMANDS.keys.join(', ')}")
80
+ return
81
+ end
82
+
83
+ method = GH_SUBCOMMANDS[sub]
84
+ unless method
85
+ run_shell_fallback(ctx, "gh", argv)
86
+ return
87
+ end
88
+
89
+ public_send(method, ctx, argv: argv[1..], kwargs: kwargs, bang: false, count: 1)
90
+ end
91
+
62
92
  def git_close_buffer(ctx, **)
63
93
  buf_id = ctx.buffer.id
64
94
  ctx.editor.git_stream_stop_handler&.call(buf_id)
@@ -67,6 +97,17 @@ module RuVim
67
97
 
68
98
  private
69
99
 
100
+ def run_shell_fallback(ctx, cmd, argv)
101
+ command = ([cmd] + argv).join(" ")
102
+ executor = ctx.editor.shell_executor
103
+ if executor
104
+ status = executor.call(command)
105
+ ctx.editor.echo("shell exit #{status.exitstatus}")
106
+ else
107
+ ctx.editor.echo_error("Unknown #{cmd} subcommand: #{argv.first}")
108
+ end
109
+ end
110
+
70
111
  def git_resolve_path(ctx)
71
112
  path = ctx.buffer.path
72
113
  return path if path && File.exist?(path)