better_structure_sql 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/README.md +557 -0
  5. data/app/controllers/better_structure_sql/application_controller.rb +61 -0
  6. data/app/controllers/better_structure_sql/schema_versions_controller.rb +243 -0
  7. data/app/helpers/better_structure_sql/schema_versions_helper.rb +46 -0
  8. data/app/views/better_structure_sql/schema_versions/index.html.erb +110 -0
  9. data/app/views/better_structure_sql/schema_versions/show.html.erb +186 -0
  10. data/app/views/layouts/better_structure_sql/application.html.erb +105 -0
  11. data/config/database.yml +3 -0
  12. data/config/routes.rb +12 -0
  13. data/lib/better_structure_sql/adapters/base_adapter.rb +234 -0
  14. data/lib/better_structure_sql/adapters/mysql_adapter.rb +476 -0
  15. data/lib/better_structure_sql/adapters/mysql_config.rb +32 -0
  16. data/lib/better_structure_sql/adapters/postgresql_adapter.rb +646 -0
  17. data/lib/better_structure_sql/adapters/postgresql_config.rb +25 -0
  18. data/lib/better_structure_sql/adapters/registry.rb +115 -0
  19. data/lib/better_structure_sql/adapters/sqlite_adapter.rb +644 -0
  20. data/lib/better_structure_sql/adapters/sqlite_config.rb +26 -0
  21. data/lib/better_structure_sql/configuration.rb +129 -0
  22. data/lib/better_structure_sql/database_version.rb +46 -0
  23. data/lib/better_structure_sql/dependency_resolver.rb +63 -0
  24. data/lib/better_structure_sql/dumper.rb +544 -0
  25. data/lib/better_structure_sql/engine.rb +28 -0
  26. data/lib/better_structure_sql/file_writer.rb +180 -0
  27. data/lib/better_structure_sql/formatter.rb +70 -0
  28. data/lib/better_structure_sql/generators/base.rb +33 -0
  29. data/lib/better_structure_sql/generators/domain_generator.rb +22 -0
  30. data/lib/better_structure_sql/generators/extension_generator.rb +23 -0
  31. data/lib/better_structure_sql/generators/foreign_key_generator.rb +43 -0
  32. data/lib/better_structure_sql/generators/function_generator.rb +33 -0
  33. data/lib/better_structure_sql/generators/index_generator.rb +50 -0
  34. data/lib/better_structure_sql/generators/materialized_view_generator.rb +31 -0
  35. data/lib/better_structure_sql/generators/pragma_generator.rb +23 -0
  36. data/lib/better_structure_sql/generators/sequence_generator.rb +27 -0
  37. data/lib/better_structure_sql/generators/table_generator.rb +126 -0
  38. data/lib/better_structure_sql/generators/trigger_generator.rb +54 -0
  39. data/lib/better_structure_sql/generators/type_generator.rb +47 -0
  40. data/lib/better_structure_sql/generators/view_generator.rb +27 -0
  41. data/lib/better_structure_sql/introspection/extensions.rb +29 -0
  42. data/lib/better_structure_sql/introspection/foreign_keys.rb +29 -0
  43. data/lib/better_structure_sql/introspection/functions.rb +29 -0
  44. data/lib/better_structure_sql/introspection/indexes.rb +29 -0
  45. data/lib/better_structure_sql/introspection/sequences.rb +29 -0
  46. data/lib/better_structure_sql/introspection/tables.rb +29 -0
  47. data/lib/better_structure_sql/introspection/triggers.rb +29 -0
  48. data/lib/better_structure_sql/introspection/types.rb +37 -0
  49. data/lib/better_structure_sql/introspection/views.rb +41 -0
  50. data/lib/better_structure_sql/introspection.rb +31 -0
  51. data/lib/better_structure_sql/manifest_generator.rb +65 -0
  52. data/lib/better_structure_sql/migration_patch.rb +196 -0
  53. data/lib/better_structure_sql/pg_version.rb +44 -0
  54. data/lib/better_structure_sql/railtie.rb +124 -0
  55. data/lib/better_structure_sql/schema_loader.rb +168 -0
  56. data/lib/better_structure_sql/schema_version.rb +86 -0
  57. data/lib/better_structure_sql/schema_versions.rb +213 -0
  58. data/lib/better_structure_sql/version.rb +5 -0
  59. data/lib/better_structure_sql/zip_generator.rb +81 -0
  60. data/lib/better_structure_sql.rb +81 -0
  61. data/lib/generators/better_structure_sql/install_generator.rb +44 -0
  62. data/lib/generators/better_structure_sql/migration_generator.rb +34 -0
  63. data/lib/generators/better_structure_sql/templates/README +49 -0
  64. data/lib/generators/better_structure_sql/templates/add_metadata_migration.rb.erb +25 -0
  65. data/lib/generators/better_structure_sql/templates/better_structure_sql.rb +46 -0
  66. data/lib/generators/better_structure_sql/templates/migration.rb.erb +26 -0
  67. data/lib/tasks/better_structure_sql.rake +190 -0
  68. metadata +299 -0
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Controller for browsing and downloading stored schema versions
5
+ #
6
+ # Provides web UI actions for listing, viewing, and downloading schema
7
+ # versions stored in the database. Implements memory-efficient streaming
8
+ # for large files and multi-file ZIP archive support.
9
+ #
10
+ # @see SchemaVersion
11
+ class SchemaVersionsController < ApplicationController
12
+ # Maximum file size to load into memory (2MB)
13
+ MAX_MEMORY_SIZE = 2.megabytes
14
+ # Maximum file size to display in browser (200KB)
15
+ MAX_DISPLAY_SIZE = 200.kilobytes
16
+
17
+ # Lists stored schema versions with pagination
18
+ #
19
+ # Loads only metadata (no content or zip_archive) for performance.
20
+ # Displays up to 100 most recent versions ordered by creation date.
21
+ #
22
+ # @return [void]
23
+ # GET /better_structure_sql/schema_versions
24
+ def index
25
+ # Load only metadata for listing (no content or zip_archive)
26
+ @schema_versions = SchemaVersion
27
+ .select(:id, :pg_version, :format_type, :output_mode, :created_at,
28
+ :content_size, :file_count)
29
+ .order(created_at: :desc)
30
+ .limit(100)
31
+ end
32
+
33
+ # Displays details of a specific schema version
34
+ #
35
+ # Loads metadata first for performance. For small single-file versions
36
+ # (under 200KB), loads content for inline display. For multi-file versions,
37
+ # extracts and parses the embedded manifest JSON.
38
+ #
39
+ # @return [void]
40
+ # @raise [ActiveRecord::RecordNotFound] if schema version not found
41
+ # GET /better_structure_sql/schema_versions/:id
42
+ def show
43
+ # Load metadata first
44
+ @schema_version = SchemaVersion
45
+ .select(:id, :pg_version, :format_type, :output_mode, :created_at,
46
+ :content_size, :line_count, :file_count)
47
+ .find(params[:id])
48
+
49
+ # Only load content for small single-file versions
50
+ if @schema_version.output_mode == 'single_file' && @schema_version.content_size <= MAX_DISPLAY_SIZE
51
+ @schema_version = SchemaVersion.find(params[:id]) # Load with content
52
+ elsif @schema_version.output_mode == 'multi_file'
53
+ # Load content to extract manifest
54
+ full_version = SchemaVersion.select(:id, :content).find(params[:id])
55
+ @manifest = extract_manifest_from_content(full_version.content)
56
+ end
57
+ rescue ActiveRecord::RecordNotFound
58
+ render plain: 'Schema version not found', status: :not_found
59
+ end
60
+
61
+ # Downloads raw content of a schema version as plain text
62
+ #
63
+ # Streams large files (>2MB) in chunks to avoid memory issues.
64
+ # Smaller files are sent directly using send_data.
65
+ #
66
+ # @return [void]
67
+ # @raise [ActiveRecord::RecordNotFound] if schema version not found
68
+ # GET /better_structure_sql/schema_versions/:id/raw
69
+ def raw
70
+ version = SchemaVersion.select(:id, :format_type, :content_size).find(params[:id])
71
+
72
+ filename = "schema_version_#{version.id}_#{version.format_type}.txt"
73
+
74
+ # For large files, stream from database to avoid loading into memory
75
+ if version.content_size > MAX_MEMORY_SIZE
76
+ stream_large_file(version.id, filename)
77
+ else
78
+ # For smaller files, use regular send_data
79
+ content = SchemaVersion.find(version.id).content
80
+ send_data content,
81
+ filename: filename,
82
+ type: 'text/plain',
83
+ disposition: 'attachment'
84
+ end
85
+ rescue ActiveRecord::RecordNotFound
86
+ render plain: 'Schema version not found', status: :not_found
87
+ end
88
+
89
+ # Downloads schema version in appropriate format
90
+ #
91
+ # Multi-file versions with ZIP archives are sent as .zip files.
92
+ # Single-file versions are sent as .sql or .rb files based on format_type.
93
+ #
94
+ # @return [void]
95
+ # @raise [ActiveRecord::RecordNotFound] if schema version not found
96
+ # GET /better_structure_sql/schema_versions/:id/download
97
+ def download
98
+ version = SchemaVersion.find(params[:id])
99
+
100
+ if version.multi_file? && version.zip_archive?
101
+ send_zip_download(version)
102
+ else
103
+ send_file_download(version)
104
+ end
105
+ rescue ActiveRecord::RecordNotFound
106
+ render plain: 'Schema version not found', status: :not_found
107
+ end
108
+
109
+ private
110
+
111
+ # Sends ZIP archive download for multi-file schema versions
112
+ #
113
+ # Validates ZIP archive before sending to prevent corrupted downloads.
114
+ # Filename includes version ID and timestamp.
115
+ #
116
+ # @param version [SchemaVersion] the schema version to download
117
+ # @return [void]
118
+ def send_zip_download(version)
119
+ # Validate ZIP
120
+ BetterStructureSql::ZipGenerator.validate_zip!(version.zip_archive)
121
+
122
+ filename = "schema_version_#{version.id}_#{version.created_at.to_i}.zip"
123
+
124
+ send_data version.zip_archive,
125
+ filename: filename,
126
+ type: 'application/zip',
127
+ disposition: 'attachment'
128
+ end
129
+
130
+ # Sends single-file schema version download
131
+ #
132
+ # Streams large files (>2MB) to avoid memory issues. Filename is
133
+ # structure.sql or structure.rb based on format_type.
134
+ #
135
+ # @param version [SchemaVersion] the schema version to download
136
+ # @return [void]
137
+ def send_file_download(version)
138
+ extension = version.format_type == 'rb' ? 'rb' : 'sql'
139
+ filename = "structure.#{extension}"
140
+
141
+ # Handle large files with streaming
142
+ if version.content_size > MAX_MEMORY_SIZE
143
+ stream_large_content(version, filename)
144
+ else
145
+ send_data version.content,
146
+ filename: filename,
147
+ type: 'text/plain',
148
+ disposition: 'attachment'
149
+ end
150
+ end
151
+
152
+ # Streams large content in 64KB chunks to avoid memory issues
153
+ #
154
+ # Sets response headers for streaming and disables proxy buffering.
155
+ # Fetches content from database and yields chunks via Enumerator.
156
+ #
157
+ # @param version [SchemaVersion] the schema version to stream
158
+ # @param filename [String] the filename for Content-Disposition header
159
+ # @return [void]
160
+ def stream_large_content(version, filename)
161
+ response.headers['Content-Type'] = 'text/plain'
162
+ response.headers['Content-Disposition'] = "attachment; filename=\"#{filename}\""
163
+ response.headers['X-Accel-Buffering'] = 'no'
164
+
165
+ self.response_body = Enumerator.new do |yielder|
166
+ content = SchemaVersion.connection.select_value(
167
+ "SELECT content FROM #{SchemaVersion.table_name} WHERE id = #{version.id}"
168
+ )
169
+
170
+ chunk_size = 64.kilobytes
171
+ offset = 0
172
+ while offset < content.bytesize
173
+ yielder << content.byteslice(offset, chunk_size)
174
+ offset += chunk_size
175
+ end
176
+ end
177
+ end
178
+
179
+ # Extracts embedded manifest JSON from multi-file schema content
180
+ #
181
+ # Manifest is stored between MANIFEST_JSON_START and MANIFEST_JSON_END markers
182
+ # as SQL comments. Parses and returns the manifest hash.
183
+ #
184
+ # @param content [String] the schema content containing embedded manifest
185
+ # @return [Hash, nil] parsed manifest hash or nil if not found/invalid
186
+ def extract_manifest_from_content(content)
187
+ # Manifest is embedded in content between MANIFEST_JSON_START and MANIFEST_JSON_END markers
188
+ return nil unless content.include?('MANIFEST_JSON_START')
189
+
190
+ # Extract JSON from between markers, removing comment prefixes
191
+ start_marker = '-- MANIFEST_JSON_START'
192
+ end_marker = '-- MANIFEST_JSON_END'
193
+
194
+ start_pos = content.index(start_marker)
195
+ end_pos = content.index(end_marker)
196
+
197
+ return nil unless start_pos && end_pos
198
+
199
+ manifest_section = content[(start_pos + start_marker.length)..(end_pos - 1)]
200
+ manifest_json = manifest_section.lines
201
+ .map { |line| line.sub(/^--\s?/, '') }
202
+ .join
203
+
204
+ JSON.parse(manifest_json)
205
+ rescue JSON::ParserError => e
206
+ Rails.logger.debug { "Failed to parse manifest: #{e.message}" }
207
+ nil
208
+ end
209
+
210
+ # Streams large file content from database in chunks
211
+ #
212
+ # Sets appropriate headers for streaming downloads and disables proxy buffering.
213
+ # Fetches content from database and streams in 64KB chunks via Enumerator.
214
+ #
215
+ # @param version_id [Integer] the schema version ID
216
+ # @param filename [String] the filename for Content-Disposition header
217
+ # @return [void]
218
+ def stream_large_file(version_id, filename)
219
+ # Set headers for streaming
220
+ response.headers['Content-Type'] = 'text/plain'
221
+ response.headers['Content-Disposition'] = "attachment; filename=\"#{filename}\""
222
+ response.headers['Cache-Control'] = 'no-cache'
223
+ response.headers['X-Accel-Buffering'] = 'no' # Disable proxy buffering
224
+
225
+ # Stream the content in chunks
226
+ self.response_body = Enumerator.new do |yielder|
227
+ # Fetch content in a streaming fashion from database
228
+ SchemaVersion.connection.select_value(
229
+ "SELECT content FROM #{SchemaVersion.table_name} WHERE id = #{version_id}"
230
+ ).tap do |content|
231
+ # Stream in 64KB chunks
232
+ chunk_size = 64.kilobytes
233
+ offset = 0
234
+
235
+ while offset < content.bytesize
236
+ yielder << content.byteslice(offset, chunk_size)
237
+ offset += chunk_size
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # View helper methods for schema versions display
5
+ #
6
+ # Provides formatting methods for rendering schema version attributes
7
+ # in the web UI, including badges and icons for output modes and format types.
8
+ module SchemaVersionsHelper
9
+ # Formats output mode as a Bootstrap badge with icon
10
+ #
11
+ # @param mode [String] the output mode ('multi_file' or 'single_file')
12
+ # @return [String] HTML-safe badge element with icon
13
+ def format_output_mode(mode)
14
+ case mode
15
+ when 'multi_file'
16
+ content_tag(:span, class: 'badge bg-info') do
17
+ concat content_tag(:i, '', class: 'bi bi-folder')
18
+ concat ' Multi-File'
19
+ end
20
+ when 'single_file'
21
+ content_tag(:span, class: 'badge bg-secondary') do
22
+ concat content_tag(:i, '', class: 'bi bi-file-earmark')
23
+ concat ' Single File'
24
+ end
25
+ else
26
+ content_tag(:span, 'Unknown', class: 'badge bg-warning')
27
+ end
28
+ end
29
+
30
+ # Formats format type as a Bootstrap badge with icon
31
+ #
32
+ # SQL format uses blue badge with SQL icon, Ruby format uses green
33
+ # badge with Ruby icon.
34
+ #
35
+ # @param format_type [String] the format type ('sql' or 'rb')
36
+ # @return [String] HTML-safe badge element with icon
37
+ def format_type_badge(format_type)
38
+ bg_class = format_type == 'sql' ? 'bg-primary' : 'bg-success'
39
+ icon_class = format_type == 'sql' ? 'bi-filetype-sql' : 'bi-filetype-rb'
40
+ content_tag(:span, class: "badge #{bg_class}") do
41
+ concat content_tag(:i, '', class: "bi #{icon_class}")
42
+ concat " #{format_type.upcase}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,110 @@
1
+ <div class="row">
2
+ <div class="col-12">
3
+ <div class="d-flex justify-content-between align-items-center mb-4">
4
+ <h1 class="h2">
5
+ <i class="bi bi-database-fill-gear text-primary"></i>
6
+ Schema Versions
7
+ </h1>
8
+ <div class="text-muted">
9
+ <i class="bi bi-info-circle"></i>
10
+ Total: <%= @schema_versions.count %>
11
+ </div>
12
+ </div>
13
+
14
+ <% if @schema_versions.empty? %>
15
+ <div class="empty-state">
16
+ <i class="bi bi-inbox" style="font-size: 4rem;"></i>
17
+ <h3 class="mt-3">No Schema Versions Yet</h3>
18
+ <p class="text-muted">
19
+ Schema versions will appear here after running:<br>
20
+ <code>rails db:schema:dump</code> (with versioning enabled)
21
+ </p>
22
+ </div>
23
+ <% else %>
24
+ <div class="card shadow-sm">
25
+ <div class="table-responsive">
26
+ <table class="table table-hover table-striped mb-0">
27
+ <thead class="table-light">
28
+ <tr>
29
+ <th scope="col" class="text-center" style="width: 80px;">ID</th>
30
+ <th scope="col" style="width: 120px;">Format</th>
31
+ <th scope="col" style="width: 150px;">Mode</th>
32
+ <th scope="col" style="width: 180px;">PostgreSQL</th>
33
+ <th scope="col">Created At</th>
34
+ <th scope="col" style="width: 100px;">Files</th>
35
+ <th scope="col" style="width: 120px;">Size</th>
36
+ <th scope="col" class="text-end" style="width: 200px;">Actions</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <% @schema_versions.each do |version| %>
41
+ <tr>
42
+ <td class="text-center fw-bold text-muted">
43
+ #<%= version.id %>
44
+ </td>
45
+ <td>
46
+ <%= format_type_badge(version.format_type) %>
47
+ </td>
48
+ <td>
49
+ <%= format_output_mode(version.output_mode) %>
50
+ </td>
51
+ <td>
52
+ <span class="text-muted">
53
+ <i class="bi bi-server"></i>
54
+ <%= version.pg_version %>
55
+ </span>
56
+ </td>
57
+ <td>
58
+ <i class="bi bi-calendar-event"></i>
59
+ <%= version.created_at.strftime('%Y-%m-%d %H:%M:%S') %>
60
+ <small class="text-muted d-block">
61
+ <%= time_ago_in_words(version.created_at) %> ago
62
+ </small>
63
+ </td>
64
+ <td>
65
+ <% if version.file_count %>
66
+ <span class="badge bg-light text-dark">
67
+ <%= version.file_count %> files
68
+ </span>
69
+ <% else %>
70
+ <span class="text-muted">1 file</span>
71
+ <% end %>
72
+ </td>
73
+ <td>
74
+ <span class="badge bg-secondary">
75
+ <%= version.formatted_size %>
76
+ </span>
77
+ </td>
78
+ <td class="text-end">
79
+ <div class="btn-group btn-group-sm" role="group">
80
+ <a href="<%= better_structure_sql.schema_version_path(version) %>"
81
+ class="btn btn-outline-primary"
82
+ title="View details">
83
+ <i class="bi bi-eye"></i>
84
+ View
85
+ </a>
86
+ <a href="<%= better_structure_sql.download_schema_version_path(version) %>"
87
+ class="btn btn-outline-success"
88
+ title="Download"
89
+ data-turbo="false">
90
+ <i class="bi bi-download"></i>
91
+ Download
92
+ </a>
93
+ </div>
94
+ </td>
95
+ </tr>
96
+ <% end %>
97
+ </tbody>
98
+ </table>
99
+ </div>
100
+ </div>
101
+
102
+ <% if @schema_versions.count >= 100 %>
103
+ <div class="alert alert-info mt-3">
104
+ <i class="bi bi-info-circle"></i>
105
+ Showing the 100 most recent versions. Older versions are still stored in the database.
106
+ </div>
107
+ <% end %>
108
+ <% end %>
109
+ </div>
110
+ </div>
@@ -0,0 +1,186 @@
1
+ <div class="row">
2
+ <div class="col-12">
3
+ <!-- Header with Back Button -->
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h1 class="h2">
6
+ <i class="bi bi-file-earmark-code text-primary"></i>
7
+ Schema Version #<%= @schema_version.id %>
8
+ </h1>
9
+ <a href="<%= better_structure_sql.schema_versions_path %>" class="btn btn-outline-secondary">
10
+ <i class="bi bi-arrow-left"></i>
11
+ Back to List
12
+ </a>
13
+ </div>
14
+
15
+ <!-- Metadata Card -->
16
+ <div class="card metadata-card shadow-sm mb-4">
17
+ <div class="card-body">
18
+ <div class="row">
19
+ <div class="col-md-3">
20
+ <h6 class="text-muted mb-1">
21
+ <i class="bi bi-hash"></i>
22
+ Version ID
23
+ </h6>
24
+ <p class="mb-0 fw-bold">#<%= @schema_version.id %></p>
25
+ </div>
26
+ <div class="col-md-3">
27
+ <h6 class="text-muted mb-1">
28
+ <i class="bi bi-filetype-sql"></i>
29
+ Format
30
+ </h6>
31
+ <p class="mb-0">
32
+ <%= format_type_badge(@schema_version.format_type) %>
33
+ </p>
34
+ </div>
35
+ <div class="col-md-3">
36
+ <h6 class="text-muted mb-1">
37
+ <i class="bi bi-folder"></i>
38
+ Output Mode
39
+ </h6>
40
+ <p class="mb-0">
41
+ <%= format_output_mode(@schema_version.output_mode) %>
42
+ </p>
43
+ </div>
44
+ <div class="col-md-3">
45
+ <h6 class="text-muted mb-1">
46
+ <i class="bi bi-server"></i>
47
+ PostgreSQL Version
48
+ </h6>
49
+ <p class="mb-0"><%= @schema_version.pg_version %></p>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="row mt-3">
54
+ <div class="col-md-3">
55
+ <h6 class="text-muted mb-1">
56
+ <i class="bi bi-calendar-event"></i>
57
+ Created
58
+ </h6>
59
+ <p class="mb-0">
60
+ <%= @schema_version.created_at.strftime('%Y-%m-%d %H:%M:%S') %>
61
+ <small class="text-muted d-block">
62
+ <%= time_ago_in_words(@schema_version.created_at) %> ago
63
+ </small>
64
+ </p>
65
+ </div>
66
+ <div class="col-md-3">
67
+ <h6 class="text-muted mb-1">
68
+ <i class="bi bi-file-earmark-bar-graph"></i>
69
+ Size
70
+ </h6>
71
+ <p class="mb-0">
72
+ <span class="badge bg-secondary"><%= @schema_version.formatted_size %></span>
73
+ </p>
74
+ </div>
75
+ <div class="col-md-3">
76
+ <h6 class="text-muted mb-1">
77
+ <i class="bi bi-info-circle"></i>
78
+ Lines
79
+ </h6>
80
+ <p class="mb-0"><%= number_with_delimiter(@schema_version.line_count) %> lines</p>
81
+ </div>
82
+ <div class="col-md-3">
83
+ <h6 class="text-muted mb-1">
84
+ <i class="bi bi-files"></i>
85
+ Files
86
+ </h6>
87
+ <p class="mb-0">
88
+ <% if @schema_version.file_count %>
89
+ <span class="badge bg-light text-dark"><%= @schema_version.file_count %> files</span>
90
+ <% else %>
91
+ <span class="text-muted">1 file</span>
92
+ <% end %>
93
+ </p>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <!-- Actions -->
100
+ <div class="mb-3">
101
+ <a href="<%= better_structure_sql.download_schema_version_path(@schema_version) %>"
102
+ class="btn btn-primary btn-lg"
103
+ data-turbo="false">
104
+ <% if @schema_version.multi_file? %>
105
+ <i class="bi bi-file-zip"></i>
106
+ Download ZIP Archive
107
+ <% else %>
108
+ <i class="bi bi-download"></i>
109
+ Download File
110
+ <% end %>
111
+ </a>
112
+ <% if @schema_version.respond_to?(:content) && @schema_version.content.present? %>
113
+ <button type="button" class="btn btn-outline-secondary btn-lg" onclick="copyToClipboard()">
114
+ <i class="bi bi-clipboard"></i>
115
+ Copy to Clipboard
116
+ </button>
117
+ <% end %>
118
+ </div>
119
+
120
+ <!-- Multi-file Info -->
121
+ <% if @schema_version.multi_file? && @manifest %>
122
+ <div class="alert alert-info mb-4">
123
+ <h6 class="alert-heading">
124
+ <i class="bi bi-info-circle"></i>
125
+ Multi-File Schema
126
+ </h6>
127
+ <p class="mb-2">This schema was generated in multi-file format with <%= @schema_version.file_count %> files across organized directories.</p>
128
+ <% if @manifest['directories'] %>
129
+ <p class="mb-0 small">
130
+ <strong>Directories:</strong>
131
+ <%= @manifest['directories'].keys.sort.join(', ') %>
132
+ </p>
133
+ <% end %>
134
+ </div>
135
+ <% end %>
136
+
137
+ <!-- Schema Content -->
138
+ <div class="card shadow-sm">
139
+ <div class="card-header bg-light">
140
+ <h5 class="card-title mb-0">
141
+ <i class="bi bi-code-square"></i>
142
+ Schema Content
143
+ <% if @schema_version.multi_file? %>
144
+ <small class="text-muted">(combined from all files)</small>
145
+ <% end %>
146
+ </h5>
147
+ </div>
148
+ <div class="card-body p-0">
149
+ <% if @schema_version.respond_to?(:content) && @schema_version.content.present? %>
150
+ <pre class="code-block m-0" id="schema-content"><%= @schema_version.content %></pre>
151
+ <% else %>
152
+ <div class="alert alert-warning m-3">
153
+ <i class="bi bi-exclamation-triangle"></i>
154
+ <strong>File too large to display</strong>
155
+ <p class="mb-0 mt-2">
156
+ This schema file is too large to display in the browser (limit: 200 KB).
157
+ Please use the "Download" button above to download and view it locally.
158
+ </p>
159
+ </div>
160
+ <% end %>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <script>
167
+ function copyToClipboard() {
168
+ const content = document.getElementById('schema-content').textContent;
169
+ navigator.clipboard.writeText(content).then(function() {
170
+ // Show success feedback
171
+ const btn = event.target.closest('button');
172
+ const originalHTML = btn.innerHTML;
173
+ btn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
174
+ btn.classList.remove('btn-outline-secondary');
175
+ btn.classList.add('btn-success');
176
+
177
+ setTimeout(function() {
178
+ btn.innerHTML = originalHTML;
179
+ btn.classList.remove('btn-success');
180
+ btn.classList.add('btn-outline-secondary');
181
+ }, 2000);
182
+ }, function(err) {
183
+ alert('Failed to copy to clipboard: ' + err);
184
+ });
185
+ }
186
+ </script>