Skip to content

Commit 56a9a0d

Browse files
authored
Merge pull request rails#55295 from igorkasyanchuk/open_file_in_editor
Add support to open files in the source code editor from the crash page
2 parents 3963a13 + b22fd5a commit 56a9a0d

File tree

11 files changed

+217
-27
lines changed

11 files changed

+217
-27
lines changed

actionpack/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* Allow to open source file with a crash from the browser.
2+
3+
*Igor Kasyanchuk*
4+
15
* Always check query string keys for valid encoding just like values are checked.
26

37
*Casper Smits*

actionpack/lib/action_dispatch/middleware/debug_view.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ def render(*)
5555
end
5656
end
5757

58+
def editor_url(location, line: nil)
59+
if editor = ActiveSupport::Editor.current
60+
line ||= location&.lineno
61+
absolute_path = location&.absolute_path
62+
63+
if absolute_path && line && File.exist?(absolute_path)
64+
editor.url_for(absolute_path, line)
65+
end
66+
end
67+
end
68+
5869
def protect_against_forgery?
5970
false
6071
end

actionpack/lib/action_dispatch/middleware/exception_wrapper.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,20 @@ def traces
148148
application_trace_with_ids = []
149149
framework_trace_with_ids = []
150150
full_trace_with_ids = []
151+
application_traces = application_trace.map(&:to_s)
151152

153+
full_trace = backtrace_cleaner&.clean_locations(backtrace, :all).presence || backtrace
152154
full_trace.each_with_index do |trace, idx|
155+
filtered_trace = backtrace_cleaner&.clean_frame(trace, :all) || trace
156+
153157
trace_with_id = {
154158
exception_object_id: @exception.object_id,
155159
id: idx,
156-
trace: trace
160+
trace: trace,
161+
filtered_trace: filtered_trace,
157162
}
158163

159-
if application_trace.include?(trace)
164+
if application_traces.include?(filtered_trace.to_s)
160165
application_trace_with_ids << trace_with_id
161166
else
162167
framework_trace_with_ids << trace_with_id
@@ -197,7 +202,7 @@ def rescue_response?
197202

198203
def source_extracts
199204
backtrace.map do |trace|
200-
extract_source(trace)
205+
extract_source(trace).merge(trace: trace)
201206
end
202207
end
203208

actionpack/lib/action_dispatch/middleware/templates/rescues/_source.html.erb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
<tr>
1212
<td>
1313
<pre class="line_numbers">
14-
<% source_extract[:code].each_key do |line_number| %>
15-
<span><%= line_number -%></span>
14+
<% source_extract[:code].each_key do |line| %>
15+
<% file_url = editor_url(source_extract[:trace], line: line) %>
16+
<span><%= link_to_if file_url, line, file_url -%></span>
1617
<% end %>
1718
</pre>
1819
</td>

actionpack/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@
1313
<% end %>
1414

1515
<% traces.each do |name, trace| %>
16-
<div id="<%= "#{name.gsub(/\s/, '-')}-#{error_index}" %>" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;">
16+
<div id="<%= "#{name.gsub(/\s/, '-')}-#{error_index}" %>" class="trace-container" style="display: <%= (name == trace_to_show) ? 'block' : 'none' %>;">
1717
<code class="traces">
1818
<% trace.each do |frame| %>
19-
<a class="trace-frames trace-frames-<%= error_index %>" data-exception-object-id="<%= frame[:exception_object_id] %>" data-frame-id="<%= frame[:id] %>" href="#">
20-
<%= frame[:trace] %>
21-
</a>
22-
<br>
19+
<div class="trace">
20+
<% file_url = editor_url(frame[:trace]) %>
21+
<%= file_url && link_to("✏️", file_url, class: "edit-icon") %>
22+
<a class="trace-frames trace-frames-<%= error_index %>" data-exception-object-id="<%= frame[:exception_object_id] %>" data-frame-id="<%= frame[:id] %>" href="#">
23+
<%= frame[:trace] %>
24+
</a>
25+
<br>
26+
</div>
2327
<% end %>
2428
</code>
2529
</div>

actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@
5454
font-size: 11px;
5555
}
5656

57+
.trace-container {
58+
margin-top: 10px;
59+
}
60+
61+
code.traces .trace {
62+
display: flex;
63+
align-items: center;
64+
gap: 2px;
65+
}
66+
67+
.edit-icon {
68+
width: 16px;
69+
height: 16px;
70+
display: flex;
71+
font-size: 13px;
72+
align-items: center;
73+
justify-content: center;
74+
text-decoration: none;
75+
}
76+
77+
.edit-icon:hover {
78+
scale: 1.05;
79+
}
80+
5781
.response-heading, .request-heading {
5882
margin-top: 30px;
5983
}

actionpack/test/dispatch/debug_exceptions_test.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,16 @@ def self.build_app(app, *args)
868868
end
869869
end
870870

871+
test "shows the link to edit the file in the editor" do
872+
@app = DevelopmentApp
873+
ActiveSupport::Editor.stub(:current, ActiveSupport::Editor.find("atom")) do
874+
get "/actionable_error"
875+
876+
assert_select "code a.edit-icon"
877+
assert_includes body, "atom://core/open"
878+
end
879+
end
880+
871881
test "shows a buttons for every action in an actionable error" do
872882
@app = DevelopmentApp
873883
Rails.stub :root, Pathname.new(".") do

actionpack/test/dispatch/exception_wrapper_test.rb

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ def translate_location(backtrace_location, spot)
5656

5757
test "#source_extracts fetches source fragments for every backtrace entry" do
5858
exception = begin index; rescue TestError => ex; ex; end
59+
5960
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1))
61+
trace = wrapper.source_extracts.first[:trace]
6062

6163
assert_called_with(wrapper, :source_fragment, ["lib/file.rb", 42], returns: "foo") do
62-
assert_equal [ code: "foo", line_number: 42 ], wrapper.source_extracts
64+
assert_equal [ code: "foo", line_number: 42, trace: trace ], wrapper.source_extracts
6365
end
6466
end
6567

@@ -69,9 +71,10 @@ def translate_location(backtrace_location, spot)
6971
exc = begin ms_index; rescue TestError => ex; ex; end
7072

7173
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exc, 1))
74+
trace = wrapper.source_extracts.first[:trace]
7275

7376
assert_called_with(wrapper, :source_fragment, ["c:/path/to/rails/app/controller.rb", 27], returns: "nothing") do
74-
assert_equal [ code: "nothing", line_number: 27 ], wrapper.source_extracts
77+
assert_equal [ code: "nothing", line_number: 27, trace: trace ], wrapper.source_extracts
7578
end
7679
end
7780

@@ -81,9 +84,10 @@ def translate_location(backtrace_location, spot)
8184
exc = begin invalid_ex; rescue TestError => ex; ex; end
8285

8386
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exc, 1))
87+
trace = wrapper.source_extracts.first[:trace]
8488

8589
assert_called_with(wrapper, :source_fragment, ["invalid", 0], returns: "nothing") do
86-
assert_equal [ code: "nothing", line_number: 0 ], wrapper.source_extracts
90+
assert_equal [ code: "nothing", line_number: 0, trace: trace ], wrapper.source_extracts
8791
end
8892
end
8993

@@ -95,9 +99,10 @@ def translate_location(backtrace_location, spot)
9599
exception = begin throw_syntax_error; rescue SyntaxError => ex; ex; end
96100

97101
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1))
102+
trace = wrapper.source_extracts.first[:trace]
98103

99104
assert_called_with(wrapper, :source_fragment, ["lib/file.rb", 42], returns: "foo") do
100-
assert_equal [ code: "foo", line_number: 42 ], wrapper.source_extracts
105+
assert_equal [ code: "foo", line_number: 42, trace: trace ], wrapper.source_extracts
101106
end
102107
end
103108

@@ -123,7 +128,7 @@ def translate_location(backtrace_location, spot)
123128
code[lineno + i] = line
124129
end
125130
code[lineno + 2] = [" 1", ".time", "\n"]
126-
assert_equal({ code: code, line_number: lineno + 2 }, wrapper.source_extracts.first)
131+
assert_equal({ code: code, line_number: lineno + 2, trace: wrapper.source_extracts.first[:trace] }, wrapper.source_extracts.first)
127132
end
128133

129134
class_eval "def _app_views_tests_show_html_erb;
@@ -142,7 +147,8 @@ def translate_location(backtrace_location, spot)
142147

143148
assert_equal [{
144149
code: { 1 => "translated @ _app_views_tests_show_html_erb:3" },
145-
line_number: 1
150+
line_number: 1,
151+
trace: wrapper.source_extracts.first[:trace]
146152
}], wrapper.source_extracts
147153
end
148154

@@ -168,17 +174,20 @@ def translate_location(backtrace_location, spot)
168174
extracts = wrapper.source_extracts
169175
assert_equal({
170176
code: { 1 => "translated @ _app_views_tests_nested_html_erb:5" },
171-
line_number: 1
177+
line_number: 1,
178+
trace: extracts[0][:trace]
172179
}, extracts[0])
173180
# extracts[1] is Array#each (unreliable backtrace across rubies)
174181
assert_equal({
175182
code: { 1 => "translated @ _app_views_tests_nested_html_erb:4" },
176-
line_number: 1
183+
line_number: 1,
184+
trace: extracts[2][:trace]
177185
}, extracts[2])
178186
# extracts[3] is Array#each (unreliable backtrace across rubies)
179187
assert_equal({
180188
code: { 1 => "translated @ _app_views_tests_nested_html_erb:3" },
181-
line_number: 1
189+
line_number: 1,
190+
trace: extracts[4][:trace]
182191
}, extracts[4])
183192
end
184193

@@ -263,23 +272,27 @@ def translate_location(backtrace_location, spot)
263272
"Application Trace" => [
264273
exception_object_id: exception.object_id,
265274
id: 0,
266-
trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'"
275+
trace: wrapper.source_extracts.first[:trace],
276+
filtered_trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'"
267277
],
268278
"Framework Trace" => [
269279
exception_object_id: exception.object_id,
270280
id: 1,
271-
trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'"
281+
trace: wrapper.source_extracts.second[:trace],
282+
filtered_trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'"
272283
],
273284
"Full Trace" => [
274285
{
275286
exception_object_id: exception.object_id,
276287
id: 0,
277-
trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'"
288+
trace: wrapper.source_extracts.first[:trace],
289+
filtered_trace: "lib/file.rb:42:in 'ActionDispatch::ExceptionWrapperTest#index'"
278290
},
279291
{
280292
exception_object_id: exception.object_id,
281293
id: 1,
282-
trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'"
294+
trace: wrapper.source_extracts.second[:trace],
295+
filtered_trace: "/gems/rack.rb:43:in 'ActionDispatch::ExceptionWrapperTest#in_rack'"
283296
}
284297
]
285298
}.inspect, wrapper.traces.inspect)
@@ -288,23 +301,27 @@ def translate_location(backtrace_location, spot)
288301
"Application Trace" => [
289302
exception_object_id: exception.object_id,
290303
id: 0,
291-
trace: "lib/file.rb:42:in `index'"
304+
trace: wrapper.source_extracts.first[:trace],
305+
filtered_trace: "lib/file.rb:42:in `index'"
292306
],
293307
"Framework Trace" => [
294308
exception_object_id: exception.object_id,
295309
id: 1,
296-
trace: "/gems/rack.rb:43:in `in_rack'"
310+
trace: wrapper.source_extracts.last[:trace],
311+
filtered_trace: "/gems/rack.rb:43:in `in_rack'"
297312
],
298313
"Full Trace" => [
299314
{
300315
exception_object_id: exception.object_id,
301316
id: 0,
302-
trace: "lib/file.rb:42:in `index'"
317+
trace: wrapper.source_extracts.first[:trace],
318+
filtered_trace: "lib/file.rb:42:in `index'"
303319
},
304320
{
305321
exception_object_id: exception.object_id,
306322
id: 1,
307-
trace: "/gems/rack.rb:43:in `in_rack'"
323+
trace: wrapper.source_extracts.last[:trace],
324+
filtered_trace: "/gems/rack.rb:43:in `in_rack'"
308325
}
309326
]
310327
}.inspect, wrapper.traces.inspect)

activesupport/lib/active_support.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ module ActiveSupport
4444
autoload :CurrentAttributes
4545
autoload :Dependencies
4646
autoload :DescendantsTracker
47+
autoload :Editor
4748
autoload :ExecutionWrapper
4849
autoload :Executor
4950
autoload :ErrorReporter
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
# :markup: markdown
4+
5+
module ActiveSupport
6+
class Editor
7+
@editors = {}
8+
@current = false
9+
10+
class << self
11+
# Registers a URL pattern for opening file in a given editor.
12+
# This allows Rails to generate clickable links to control known editors.
13+
#
14+
# Example:
15+
#
16+
# ActiveSupport::Editor.register("myeditor", "myeditor://%s:%d")
17+
def register(name, url_pattern, aliases: [])
18+
editor = new(url_pattern)
19+
@editors[name] = editor
20+
aliases.each do |a|
21+
@editors[a] = editor
22+
end
23+
end
24+
25+
# Returns the current editor pattern if it is known.
26+
# First check for the `RAILS_EDITOR` environment variable, and if it's
27+
# missing, check for the `EDITOR` environment variable.
28+
def current
29+
if @current == false
30+
@current = if editor_name = ENV["RAILS_EDITOR"] || ENV["EDITOR"]
31+
@editors[editor_name]
32+
end
33+
end
34+
@current
35+
end
36+
37+
# :nodoc:
38+
39+
def find(name)
40+
@editors[name]
41+
end
42+
43+
def reset
44+
@current = false
45+
end
46+
end
47+
48+
def initialize(url_pattern)
49+
@url_pattern = url_pattern
50+
end
51+
52+
def url_for(path, line)
53+
sprintf(@url_pattern, path, line)
54+
end
55+
56+
register "atom", "atom://core/open/file?filename=%s&line=%d"
57+
register "cursor", "cursor://file/%s:%f"
58+
register "emacs", "emacs://open?url=file://%s&line=%d", aliases: ["emacsclient"]
59+
register "idea", "idea://open?file=%s&line=%d"
60+
register "macvim", "mvim://open?url=file://%s&line=%d", aliases: ["mvim"]
61+
register "nova", "nova://open?path=%s&line=%d"
62+
register "rubymine", "x-mine://open?file=%s&line=%d"
63+
register "sublime", "subl://open?url=file://%s&line=%d", aliases: ["subl"]
64+
register "textmate", "txmt://open?url=file://%s&line=%d", aliases: ["mate"]
65+
register "vscode", "vscode://file/%s:%d", aliases: ["code"]
66+
register "vscodium", "vscodium://file/%s:%d", aliases: ["codium"]
67+
register "windsurf", "windsurf://file/%s:%d"
68+
register "zed", "zed://file/%s:%d"
69+
end
70+
end

0 commit comments

Comments
 (0)