rails-wayback 0.1.0 → 0.2.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -7
  3. data/Rakefile +17 -1
  4. data/app/controllers/rails_wayback/assets_controller.rb +78 -0
  5. data/app/controllers/rails_wayback/pages_controller.rb +50 -9
  6. data/config/routes.rb +5 -0
  7. data/lib/generators/rails_wayback/install/install_generator.rb +5 -3
  8. data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -1
  9. data/lib/rails_wayback/asset_provenance.rb +41 -0
  10. data/lib/rails_wayback/bar_middleware.rb +235 -67
  11. data/lib/rails_wayback/bar_renderer.css +76 -0
  12. data/lib/rails_wayback/bar_renderer.html.erb +43 -0
  13. data/lib/rails_wayback/bar_renderer.js +361 -0
  14. data/lib/rails_wayback/bar_renderer.rb +70 -218
  15. data/lib/rails_wayback/bar_search.js +73 -0
  16. data/lib/rails_wayback/cache_inventory.rb +199 -0
  17. data/lib/rails_wayback/cli.rb +70 -3
  18. data/lib/rails_wayback/configuration.rb +13 -2
  19. data/lib/rails_wayback/controller_extensions.rb +71 -20
  20. data/lib/rails_wayback/doctor.rb +123 -0
  21. data/lib/rails_wayback/engine.rb +27 -19
  22. data/lib/rails_wayback/engine_mount.rb +15 -0
  23. data/lib/rails_wayback/git.rb +92 -8
  24. data/lib/rails_wayback/historical_asset_body.rb +41 -0
  25. data/lib/rails_wayback/historical_asset_helper.rb +68 -0
  26. data/lib/rails_wayback/historical_asset_resolver.rb +102 -0
  27. data/lib/rails_wayback/ref_policy.rb +64 -0
  28. data/lib/rails_wayback/render_context.rb +24 -0
  29. data/lib/rails_wayback/render_provenance.rb +84 -0
  30. data/lib/rails_wayback/render_tracker.rb +34 -0
  31. data/lib/rails_wayback/toggle.rb +10 -2
  32. data/lib/rails_wayback/version.rb +1 -1
  33. data/lib/rails_wayback/view_source.rb +171 -24
  34. data/lib/rails_wayback.rb +42 -6
  35. data/lib/tasks/rails_wayback.rake +17 -0
  36. data/rails-wayback.gemspec +2 -5
  37. metadata +21 -33
@@ -1,14 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
+ require "rails_wayback/asset_provenance"
4
5
  require "rails_wayback/bar_renderer"
6
+ require "rails_wayback/engine_mount"
7
+ require "rails_wayback/ref_policy"
5
8
 
6
9
  module RailsWayback
7
10
  # Rack middleware that appends the wayback bar to every successful
8
- # HTML response of the host application while the gem is enabled.
9
- # Skips assets, ActionCable, non-2xx responses and non-HTML bodies so
10
- # it never touches anything but the developer-facing pages.
11
+ # or client-error HTML response of the host application while the gem
12
+ # is enabled. Only complete, bounded Rack-buffered documents are
13
+ # transformed; streaming and otherwise unsafe responses pass through.
11
14
  class BarMiddleware
15
+ UNSAFE_STATUSES = [204, 205, 206].freeze
16
+ UNSAFE_RESPONSE_HEADERS = %w[
17
+ transfer-encoding
18
+ content-range
19
+ rack.hijack
20
+ x-sendfile
21
+ x-accel-redirect
22
+ ].freeze
23
+ INVALIDATED_RESPONSE_HEADERS = %w[
24
+ etag
25
+ content-md5
26
+ digest
27
+ content-digest
28
+ repr-digest
29
+ content-range
30
+ accept-ranges
31
+ ].freeze
12
32
  SKIP_PATH_PREFIXES = %w[
13
33
  /assets
14
34
  /packs
@@ -22,60 +42,163 @@ module RailsWayback
22
42
  end
23
43
 
24
44
  def call(env)
25
- # Always reset the per-request render tracker before invoking the
26
- # app so the middleware can accurately report which templates the
27
- # current page rendered. Cheap and thread-safe (Thread.current).
28
- Thread.current[RailsWayback::RENDER_TRACKER_KEY] = []
45
+ tracker = RailsWayback::RenderTracker.new
46
+ RailsWayback::RenderContext.with(tracker) { call_with_tracker(env, tracker) }
47
+ ensure
48
+ tracker&.close
49
+ end
50
+
51
+ private
29
52
 
30
- status, headers, body = @app.call(env)
31
- return [status, headers, body] unless inject?(env, status, headers)
53
+ def call_with_tracker(env, tracker)
54
+ response = @app.call(env)
55
+ status, headers, body = response
56
+ return response unless inject?(env, status, headers, body)
32
57
 
33
- html = read_body(body)
34
- snippet = build_snippet(env)
35
- new_html = inject(html, snippet)
58
+ parts = body.to_ary
59
+ return [status, headers, parts] unless body_within_limit?(parts)
60
+
61
+ html = parts.join
62
+ return [status, headers, parts] unless injectable_document?(html)
36
63
 
37
- new_headers = headers.dup
38
- length_key = new_headers.key?("Content-Length") ? "Content-Length" : (new_headers.key?("content-length") ? "content-length" : nil)
39
- new_headers[length_key] = new_html.bytesize.to_s if length_key
64
+ snippet = build_snippet(env, tracker)
65
+ return [status, headers, parts] if snippet.empty?
40
66
 
41
- [status, new_headers, [new_html]]
67
+ new_html = inject(html, snippet)
68
+ [status, repaired_headers(headers, new_html), [new_html]]
42
69
  end
43
70
 
44
- private
71
+ def inject?(env, status, headers, body)
72
+ RailsWayback.enabled? &&
73
+ request_injectable?(env) &&
74
+ response_injectable?(status, headers) &&
75
+ body_injectable?(body, headers)
76
+ end
45
77
 
46
- def inject?(env, status, headers)
47
- return false unless RailsWayback.enabled?
48
- return false unless status.to_i.between?(200, 299) || status.to_i.between?(400, 499)
78
+ def request_injectable?(env)
79
+ return false if env["REQUEST_METHOD"].to_s.casecmp?("HEAD")
49
80
 
50
81
  path = env["PATH_INFO"].to_s
51
- return false if SKIP_PATH_PREFIXES.any? { |prefix| path.start_with?(prefix) }
82
+ SKIP_PATH_PREFIXES.none? { |prefix| path.start_with?(prefix) }
83
+ end
52
84
 
53
- content_type = headers["Content-Type"] || headers["content-type"] || ""
54
- return false unless content_type.include?("text/html")
85
+ def response_injectable?(status, headers)
86
+ injectable_status?(status) &&
87
+ html_response?(headers) &&
88
+ identity_encoded?(headers) &&
89
+ !attachment?(headers) &&
90
+ !no_transform?(headers)
91
+ end
55
92
 
56
- true
93
+ def injectable_status?(status)
94
+ code = status.to_i
95
+ (code.between?(200, 299) || code.between?(400, 499)) && !UNSAFE_STATUSES.include?(code)
57
96
  end
58
97
 
59
- def read_body(body)
60
- buffer = +""
61
- body.each { |chunk| buffer << chunk.to_s }
62
- buffer
63
- ensure
64
- body.close if body.respond_to?(:close)
98
+ def body_injectable?(body, headers)
99
+ UNSAFE_RESPONSE_HEADERS.none? { |name| header_present?(headers, name) } &&
100
+ !body.respond_to?(:to_path) &&
101
+ body.respond_to?(:to_ary) &&
102
+ declared_body_within_limit?(headers)
103
+ end
104
+
105
+ def html_response?(headers)
106
+ media_type = header_value(headers, "content-type").to_s.split(";", 2).first
107
+ media_type.strip.casecmp?("text/html")
108
+ end
109
+
110
+ def identity_encoded?(headers)
111
+ value = header_value(headers, "content-encoding").to_s.strip
112
+ return true if value.empty?
113
+
114
+ value.split(",").all? { |encoding| encoding.strip.casecmp?("identity") }
115
+ end
116
+
117
+ def attachment?(headers)
118
+ header_value(headers, "content-disposition").to_s.match?(/\battachment\b/i)
119
+ end
120
+
121
+ def no_transform?(headers)
122
+ header_value(headers, "cache-control").to_s.match?(/\bno-transform\b/i)
123
+ end
124
+
125
+ def declared_body_within_limit?(headers)
126
+ length = integer_header(headers, "content-length")
127
+ return false if length&.zero?
128
+
129
+ limit = max_response_bytes
130
+ limit.nil? || length.nil? || length <= limit
131
+ end
132
+
133
+ def body_within_limit?(parts)
134
+ limit = max_response_bytes
135
+ limit.nil? || parts.sum(&:bytesize) <= limit
136
+ end
137
+
138
+ def max_response_bytes
139
+ value = RailsWayback.configuration.max_response_bytes
140
+ value.nil? ? nil : Integer(value)
141
+ end
142
+
143
+ def integer_header(headers, name)
144
+ value = header_value(headers, name)
145
+ value.nil? ? nil : Integer(value, 10)
146
+ rescue ArgumentError, TypeError
147
+ nil
148
+ end
149
+
150
+ def injectable_document?(html)
151
+ html.match?(/<body(?:\s|>)/i) &&
152
+ html.match?(%r{</body\s*>}i) &&
153
+ !html.match?(/\bid=(["'])rails-wayback-bar\1/i)
65
154
  end
66
155
 
67
156
  def inject(html, snippet)
68
- if html.include?("</body>")
69
- html.sub("</body>", "#{snippet}</body>")
70
- else
71
- html + snippet
72
- end
157
+ html.sub(%r{</body\s*>}i) { |closing_tag| "#{snippet}#{closing_tag}" }
158
+ end
159
+
160
+ def repaired_headers(headers, html)
161
+ repaired = headers.dup
162
+ INVALIDATED_RESPONSE_HEADERS.each { |name| delete_header(repaired, name) }
163
+ set_header(repaired, "content-length", html.bytesize.to_s)
164
+ add_no_store(repaired)
165
+ repaired
73
166
  end
74
167
 
75
- def build_snippet(env)
168
+ def add_no_store(headers)
169
+ current = header_value(headers, "cache-control").to_s
170
+ return if current.match?(/(?:\A|,)\s*no-store\s*(?:,|\z)/i)
171
+
172
+ set_header(headers, "cache-control", current.empty? ? "no-store" : "#{current}, no-store")
173
+ end
174
+
175
+ def header_present?(headers, name)
176
+ !header_key(headers, name).nil?
177
+ end
178
+
179
+ def header_value(headers, name)
180
+ key = header_key(headers, name)
181
+ key ? headers[key] : nil
182
+ end
183
+
184
+ def header_key(headers, name)
185
+ headers.keys.find { |key| key.to_s.casecmp?(name) }
186
+ end
187
+
188
+ def set_header(headers, name, value)
189
+ headers[header_key(headers, name) || name] = value
190
+ end
191
+
192
+ def delete_header(headers, name)
193
+ key = header_key(headers, name)
194
+ headers.delete(key) if key
195
+ end
196
+
197
+ def build_snippet(env, tracker)
76
198
  git = RailsWayback::Git.new
77
- active_ref = extract_active_ref(env)
78
- active_branch = active_ref ? active_branch_for(env, git, active_ref) : nil
199
+ selection = ref_selection(env)
200
+ active_ref = selection&.accepted? ? selection.sha : nil
201
+ active_branch = active_ref ? active_branch_for(env, git, selection) : nil
79
202
 
80
203
  renderer = BarRenderer.new(
81
204
  current_branch: safe(:current_branch, git),
@@ -83,7 +206,9 @@ module RailsWayback
83
206
  active_ref: active_ref,
84
207
  active_branch: active_branch,
85
208
  engine_mount: engine_mount,
86
- diff_info: active_ref ? build_diff_info(git, active_ref) : nil
209
+ diff_info: active_ref ? build_diff_info(git, active_ref, tracker) : nil,
210
+ csp_nonce: content_security_policy_nonce(env),
211
+ ref_error: selection&.rejected? ? selection.message : nil
87
212
  )
88
213
  renderer.render
89
214
  rescue StandardError => e
@@ -97,38 +222,81 @@ module RailsWayback
97
222
  ""
98
223
  end
99
224
 
225
+ def content_security_policy_nonce(env)
226
+ return unless defined?(ActionDispatch::Request)
227
+
228
+ request = ActionDispatch::Request.new(env)
229
+ request.content_security_policy_nonce if request.respond_to?(:content_security_policy_nonce)
230
+ rescue StandardError
231
+ nil
232
+ end
233
+
100
234
  # Determines the branch label to show in the bar while traveling.
101
235
  # Prefers the branch the user explicitly picked in the bar (passed
102
236
  # via the `_wayback_branch` query param, so it survives the reload).
103
237
  # Falls back to inferring it from git — useful when a developer opens
104
238
  # a shared URL that only carries `_wayback_ref`.
105
- def active_branch_for(env, git, ref)
239
+ def active_branch_for(env, git, selection)
106
240
  explicit = extract_active_branch(env)
107
- return explicit if explicit && !explicit.empty?
241
+ explicit_ref = trusted_ref_for(explicit, selection.trusted_refs)
242
+ return reference_label(explicit_ref) if explicit_ref
243
+
244
+ inferred = git.resolve_branch_for(selection.sha)
245
+ inferred_ref = "refs/heads/#{inferred}"
246
+ return inferred if inferred && selection.trusted_refs.include?(inferred_ref)
108
247
 
109
- git.resolve_branch_for(ref)
248
+ reference_label(selection.trusted_refs.first)
110
249
  rescue StandardError
111
250
  nil
112
251
  end
113
252
 
253
+ def trusted_ref_for(selector, trusted_refs)
254
+ return if selector.to_s.empty?
255
+ return selector if trusted_refs.include?(selector)
256
+
257
+ trusted_refs.find do |reference|
258
+ reference_name(reference) == selector || reference_label(reference) == selector
259
+ end
260
+ end
261
+
262
+ def reference_name(reference)
263
+ reference.to_s.sub(%r{\Arefs/(?:heads|tags|remotes)/}, "")
264
+ end
265
+
266
+ def reference_label(reference)
267
+ return if reference.to_s.empty?
268
+
269
+ name = reference_name(reference)
270
+ reference.start_with?("refs/tags/") ? "tag:#{name}" : name
271
+ end
272
+
114
273
  # Builds the diff summary shown in the bar when a ref is active.
115
274
  # `changed_files`: view/asset paths that differ between the ref and
116
275
  # the current working tree, always scoped to the paths the gem
117
- # actually swaps. `rendered_from_ref`: relative paths (under
118
- # app/views, app/components, ...) that the current request rendered
119
- # from the sandbox. `matched`: intersection — the templates on this
120
- # page that actually reflect a real diff between branches.
121
- def build_diff_info(git, ref)
276
+ # actually swaps. Rendered paths are split by origin so the toolbar can
277
+ # warn when a successful page silently mixes historical templates with
278
+ # current-tree fallbacks.
279
+ def build_diff_info(git, ref, tracker)
122
280
  config = RailsWayback.configuration
123
281
  tracked_paths = (config.view_paths + config.asset_paths).uniq
124
282
  changed_files = git.diff_paths(ref, paths: tracked_paths)
125
283
 
126
- rendered_from_ref = rendered_relative_paths(config)
284
+ rendered = RailsWayback::RenderProvenance.paths_by_origin(
285
+ tracker.entries,
286
+ configuration: config
287
+ )
288
+ rendered_from_ref = rendered[:historical]
289
+ rendered_from_current = rendered[:current]
290
+ assets = RailsWayback::AssetProvenance.paths_by_origin(tracker.entries)
127
291
  matched = changed_files & rendered_from_ref
128
292
 
129
293
  {
130
294
  changed_files: changed_files,
131
295
  rendered_from_ref: rendered_from_ref,
296
+ rendered_from_current: rendered_from_current,
297
+ historical_assets: assets[:historical],
298
+ current_asset_fallbacks: assets[:current],
299
+ preview_mode: preview_mode(rendered_from_ref, rendered_from_current),
132
300
  matched: matched
133
301
  }
134
302
  rescue StandardError => e
@@ -136,21 +304,12 @@ module RailsWayback
136
304
  nil
137
305
  end
138
306
 
139
- def rendered_relative_paths(config)
140
- tracker = Array(Thread.current[RailsWayback::RENDER_TRACKER_KEY])
141
- return [] if tracker.empty?
307
+ def preview_mode(historical, current)
308
+ return :mixed if historical.any? && current.any?
309
+ return :historical if historical.any?
310
+ return :current_fallback if current.any?
142
311
 
143
- sandbox_prefix = config.refs_cache_path.to_s
144
- # Sandbox identifiers look like:
145
- # /.../tmp/rails_wayback/refs/<sha>/app/views/foo/_bar.html.haml
146
- # Strip up to and including the sha directory to get back
147
- # `app/views/foo/_bar.html.haml`, which matches `git diff --name-only`.
148
- strip_re = %r{\A#{Regexp.escape(sandbox_prefix)}/[^/]+/}
149
-
150
- tracker.filter_map do |identifier|
151
- next unless identifier.start_with?(sandbox_prefix)
152
- identifier.sub(strip_re, "")
153
- end.uniq
312
+ :unknown
154
313
  end
155
314
 
156
315
  # Query params take priority (one-off overrides / shared links);
@@ -161,6 +320,17 @@ module RailsWayback
161
320
  extract_cookie(env, "rails_wayback_ref")
162
321
  end
163
322
 
323
+ def ref_selection(env)
324
+ return env[RailsWayback::RefPolicy::ENV_KEY] if env.key?(RailsWayback::RefPolicy::ENV_KEY)
325
+
326
+ ref = extract_active_ref(env)
327
+ return unless ref
328
+
329
+ RailsWayback::RefPolicy.new.authorize(ref).tap do |selection|
330
+ env[RailsWayback::RefPolicy::ENV_KEY] = selection
331
+ end
332
+ end
333
+
164
334
  def extract_active_branch(env)
165
335
  extract_query_param(env, "_wayback_branch") ||
166
336
  extract_cookie(env, "rails_wayback_branch")
@@ -169,6 +339,7 @@ module RailsWayback
169
339
  def extract_query_param(env, name)
170
340
  query = env["QUERY_STRING"].to_s
171
341
  return nil if query.empty?
342
+
172
343
  prefix = "#{name}="
173
344
  match = query.split("&").find { |pair| pair.start_with?(prefix) }
174
345
  return nil unless match
@@ -180,6 +351,7 @@ module RailsWayback
180
351
  def extract_cookie(env, name)
181
352
  header = env["HTTP_COOKIE"].to_s
182
353
  return nil if header.empty?
354
+
183
355
  prefix = "#{name}="
184
356
  match = header.split(/;\s*/).find { |pair| pair.start_with?(prefix) }
185
357
  return nil unless match
@@ -189,11 +361,7 @@ module RailsWayback
189
361
  end
190
362
 
191
363
  def engine_mount
192
- RailsWayback::Engine.routes.default_url_options[:script_name] ||
193
- RailsWayback::Engine.routes.find_script_name({}) ||
194
- "/rails-wayback"
195
- rescue StandardError
196
- "/rails-wayback"
364
+ RailsWayback::EngineMount.path
197
365
  end
198
366
  end
199
367
  end
@@ -0,0 +1,76 @@
1
+ #rails-wayback-bar {
2
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000;
3
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
4
+ font-size: 12px; color: #e6e9ef;
5
+ background: rgba(15, 17, 21, 0.96);
6
+ border-top: 1px solid #2a2f3a;
7
+ box-shadow: 0 -8px 20px rgba(0,0,0,.35);
8
+ }
9
+ #rails-wayback-bar * { box-sizing: border-box; }
10
+ #rails-wayback-bar .rw-bar-inner {
11
+ display: flex; align-items: end; gap: 12px; padding: 8px 12px;
12
+ }
13
+ #rails-wayback-bar .rw-field { display: flex; flex-direction: column; gap: 3px; min-width: 180px; }
14
+ #rails-wayback-bar .rw-flex { flex: 1; }
15
+ #rails-wayback-bar .rw-selector { display: flex; gap: 4px; }
16
+ #rails-wayback-bar label {
17
+ color: #8a93a6; text-transform: uppercase; letter-spacing: .06em; font-size: 10px;
18
+ }
19
+ #rails-wayback-bar select,
20
+ #rails-wayback-bar input[type="search"] {
21
+ background: #0b0d12; color: #e6e9ef;
22
+ border: 1px solid #2a2f3a; border-radius: 4px;
23
+ padding: 5px 8px; font-family: inherit; font-size: 12px; width: 100%;
24
+ }
25
+ #rails-wayback-bar input[type="search"] { flex: 0 1 150px; min-width: 90px; }
26
+ #rails-wayback-bar select { flex: 1; min-width: 0; }
27
+ #rails-wayback-bar select:focus-visible,
28
+ #rails-wayback-bar input[type="search"]:focus-visible {
29
+ border-color: #6ea8fe; outline: 2px solid rgba(110, 168, 254, .35); outline-offset: 1px;
30
+ }
31
+ #rails-wayback-bar input[type="search"][aria-invalid="true"] { border-color: #ff7b72; }
32
+ #rails-wayback-bar select:disabled,
33
+ #rails-wayback-bar input[type="search"]:disabled { opacity: .55; }
34
+ #rails-wayback-bar .rw-search-status { color: #8a93a6; font-size: 9px; min-height: 10px; }
35
+ #rails-wayback-bar .rw-state { color: #8a93a6; font-size: 11px; margin: 0 4px; }
36
+ #rails-wayback-bar .rw-state.rw-active { color: #ffd479; }
37
+ #rails-wayback-bar .rw-btn {
38
+ border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer;
39
+ font-family: inherit; font-size: 12px; font-weight: 600;
40
+ }
41
+ #rails-wayback-bar .rw-btn-primary { background: #6ea8fe; color: #0b0d12; }
42
+ #rails-wayback-bar .rw-btn-ghost { background: transparent; color: #8a93a6; border: 1px solid #2a2f3a; }
43
+ #rails-wayback-bar .rw-btn:disabled { opacity: .5; cursor: not-allowed; }
44
+ #rails-wayback-bar .rw-trust-notice {
45
+ border-top: 1px solid #2a2f3a; padding: 5px 12px;
46
+ color: #ffb86b; font-size: 10px;
47
+ }
48
+ #rails-wayback-bar .rw-ref-rejected { margin-left: 8px; color: #ff7b72; }
49
+ #rails-wayback-bar .rw-diff {
50
+ border-top: 1px solid #2a2f3a;
51
+ padding: 6px 12px; font-size: 11px; color: #8a93a6;
52
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
53
+ }
54
+ #rails-wayback-bar .rw-diff-match .rw-diff-msg { color: #7ee787; }
55
+ #rails-wayback-bar .rw-diff-miss .rw-diff-msg { color: #ffd479; }
56
+ #rails-wayback-bar .rw-diff-neutral .rw-diff-msg { color: #8a93a6; }
57
+ #rails-wayback-bar .rw-provenance { font-weight: 600; }
58
+ #rails-wayback-bar .rw-provenance-historical { color: #7ee787; }
59
+ #rails-wayback-bar .rw-provenance-mixed,
60
+ #rails-wayback-bar .rw-provenance-current { color: #ffb86b; }
61
+ #rails-wayback-bar .rw-diff-details { color: #8a93a6; }
62
+ #rails-wayback-bar .rw-diff-details summary {
63
+ cursor: pointer; user-select: none;
64
+ }
65
+ #rails-wayback-bar .rw-diff-details[open] {
66
+ flex-basis: 100%;
67
+ }
68
+ #rails-wayback-bar .rw-diff-details ul {
69
+ margin: 6px 0 0; padding: 6px 10px; list-style: none;
70
+ background: #0b0d12; border: 1px solid #2a2f3a; border-radius: 4px;
71
+ max-height: 200px; overflow: auto;
72
+ font-family: inherit; font-size: 11px;
73
+ }
74
+ #rails-wayback-bar .rw-diff-details li { padding: 2px 0; color: #c8cfdc; }
75
+ body { padding-bottom: 96px !important; }
76
+ body:has(#rails-wayback-bar .rw-diff) { padding-bottom: 128px !important; }
@@ -0,0 +1,43 @@
1
+ <link rel="stylesheet" href="<%= escape(stylesheet_url) %>" data-rails-wayback<%= nonce_attribute %>>
2
+ <div id="rails-wayback-bar" data-mount="<%= escape(@engine_mount) %>"
3
+ data-branch="<%= escape(@current_branch) %>"
4
+ data-commit="<%= escape(@current_commit) %>"
5
+ data-active-ref="<%= escape(@active_ref.to_s) %>"
6
+ data-active-branch="<%= escape(@active_branch.to_s) %>"
7
+ data-ref-error="<%= escape(@ref_error) %>"
8
+ data-preview-mode="<%= escape(@diff_info&.dig(:preview_mode).to_s) %>">
9
+ <div class="rw-bar-inner">
10
+ <div class="rw-field">
11
+ <label for="rails-wayback-branches"><%= escape(branch_label) %></label>
12
+ <div class="rw-selector">
13
+ <input type="search" data-rw-branch-search aria-label="Search Git refs"
14
+ aria-controls="rails-wayback-branches" placeholder="Search refs"
15
+ autocomplete="off" spellcheck="false" disabled>
16
+ <select id="rails-wayback-branches" data-rw-branch aria-label="Git ref" disabled></select>
17
+ </div>
18
+ <span class="rw-search-status" data-rw-branch-results role="status" aria-live="polite"></span>
19
+ </div>
20
+ <div class="rw-field rw-flex">
21
+ <label for="rails-wayback-commits"><%= escape(commit_label) %></label>
22
+ <div class="rw-selector">
23
+ <input type="search" data-rw-commit-search aria-label="Search commits"
24
+ aria-controls="rails-wayback-commits" placeholder="Search commits"
25
+ autocomplete="off" spellcheck="false" disabled>
26
+ <select id="rails-wayback-commits" data-rw-commit aria-label="Git commit" disabled></select>
27
+ </div>
28
+ <span class="rw-search-status" data-rw-commit-results role="status" aria-live="polite"></span>
29
+ </div>
30
+ <div class="rw-state" data-rw-state></div>
31
+ <button type="button" data-rw-travel class="rw-btn rw-btn-primary">Travel</button>
32
+ <button type="button" data-rw-reset class="rw-btn rw-btn-ghost" hidden>Return to HEAD</button>
33
+ </div>
34
+ <div class="rw-trust-notice" data-rw-trust-notice>
35
+ Historical templates execute Ruby in this process. Only travel to commits you trust.
36
+ Historical JavaScript executes in this application's browser origin.
37
+ <% unless @ref_error.empty? %>
38
+ <strong class="rw-ref-rejected"><%= escape(@ref_error) %></strong>
39
+ <% end %>
40
+ </div>
41
+ <%= diff_summary_html %>
42
+ </div>
43
+ <script src="<%= escape(javascript_url) %>" defer data-rails-wayback<%= nonce_attribute %>></script>