query_console 0.1.0 → 0.2.1

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.
@@ -1,16 +1,14 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
  <head>
4
- <title>Query Console</title>
4
+ <title>Query Console v0.2.0</title>
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <%= csrf_meta_tags %>
7
7
  <meta name="turbo-refresh-method" content="morph">
8
8
  <meta name="turbo-refresh-scroll" content="preserve">
9
9
 
10
10
  <style>
11
- * {
12
- box-sizing: border-box;
13
- }
11
+ * { box-sizing: border-box; }
14
12
 
15
13
  body {
16
14
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
@@ -21,10 +19,11 @@
21
19
  }
22
20
 
23
21
  .container {
24
- max-width: 1400px;
22
+ max-width: 1600px;
25
23
  margin: 0 auto;
26
24
  }
27
25
 
26
+ /* Banner */
28
27
  .banner {
29
28
  background: #fff3cd;
30
29
  border: 1px solid #ffc107;
@@ -33,79 +32,51 @@
33
32
  margin-bottom: 20px;
34
33
  color: #856404;
35
34
  position: relative;
36
- transition: all 0.3s ease;
37
- }
38
-
39
- .banner.collapsed {
40
- padding: 10px 15px;
41
- }
42
-
43
- .banner.collapsed .banner-content {
44
- display: none;
45
- }
46
-
47
- .banner.collapsed h2 {
48
- margin: 0;
49
35
  }
50
36
 
37
+ .banner.collapsed .banner-content { display: none; }
51
38
  .banner h2 {
52
39
  margin: 0 0 10px 0;
53
40
  font-size: 18px;
54
41
  padding-right: 30px;
55
42
  }
56
-
57
43
  .banner p {
58
44
  margin: 5px 0;
59
45
  font-size: 14px;
60
46
  }
61
47
 
62
- .banner-toggle {
48
+ .section-toggle {
63
49
  position: absolute;
64
- top: 15px;
50
+ top: 50%;
65
51
  right: 15px;
52
+ transform: translateY(-50%);
66
53
  background: transparent;
67
54
  border: none;
68
- color: #856404;
69
- font-size: 20px;
70
55
  cursor: pointer;
71
- padding: 0;
72
- width: 24px;
73
- height: 24px;
74
- display: flex;
75
- align-items: center;
76
- justify-content: center;
77
- transition: transform 0.3s ease;
78
- }
79
-
80
- .banner-toggle:hover {
81
- transform: scale(1.2);
82
- }
83
-
84
- .banner.collapsed .banner-toggle {
85
- transform: rotate(180deg);
56
+ padding: 4px 8px;
57
+ font-size: 16px;
86
58
  }
87
59
 
60
+ /* Main Layout */
88
61
  .main-layout {
89
62
  display: grid;
90
- grid-template-columns: 1fr 300px;
63
+ grid-template-columns: 1fr 400px;
91
64
  gap: 20px;
65
+ align-items: start;
92
66
  }
93
67
 
68
+ /* Editor Section */
94
69
  .editor-section {
95
70
  background: white;
96
71
  border-radius: 8px;
97
72
  padding: 20px;
98
73
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
99
- transition: all 0.3s ease;
100
- }
101
-
102
- .editor-section.collapsed {
103
- padding: 15px 20px;
104
- }
105
-
106
- .editor-section.collapsed .editor-content {
107
- display: none;
74
+ min-width: 0; /* Allow grid to constrain this column */
75
+ width: 100%; /* Take full grid column width */
76
+ position: relative;
108
77
  }
78
+
79
+ .editor-section.collapsed .editor-content { display: none; }
109
80
 
110
81
  .editor-header {
111
82
  display: flex;
@@ -113,37 +84,12 @@
113
84
  align-items: center;
114
85
  margin-bottom: 15px;
115
86
  position: relative;
116
- }
117
-
118
- .editor-section.collapsed .editor-header {
119
- margin-bottom: 0;
87
+ padding-right: 40px; /* Increased from 30px to give more space for toggle button */
120
88
  }
121
89
 
122
90
  .editor-header h3 {
123
91
  margin: 0;
124
- font-size: 20px;
125
- flex-grow: 1;
126
- }
127
-
128
- .section-toggle {
129
- background: transparent;
130
- border: none;
131
- color: #6c757d;
132
- font-size: 18px;
133
- cursor: pointer;
134
- padding: 5px 10px;
135
- margin-left: 10px;
136
- transition: transform 0.3s ease, color 0.2s;
137
- order: 1;
138
- }
139
-
140
- .section-toggle:hover {
141
- color: #007bff;
142
- transform: scale(1.2);
143
- }
144
-
145
- .collapsed .section-toggle {
146
- transform: rotate(180deg);
92
+ font-size: 16px;
147
93
  }
148
94
 
149
95
  .button-group {
@@ -151,13 +97,13 @@
151
97
  gap: 10px;
152
98
  }
153
99
 
154
- .btn-primary, .btn-secondary, .btn-danger {
155
- padding: 10px 20px;
100
+ .btn-primary, .btn-secondary {
101
+ padding: 8px 16px;
156
102
  border: none;
157
103
  border-radius: 4px;
158
- font-size: 14px;
159
104
  cursor: pointer;
160
- transition: all 0.2s;
105
+ font-size: 14px;
106
+ font-weight: 500;
161
107
  }
162
108
 
163
109
  .btn-primary {
@@ -165,15 +111,10 @@
165
111
  color: white;
166
112
  }
167
113
 
168
- .btn-primary:hover:not(:disabled) {
114
+ .btn-primary:hover {
169
115
  background: #0056b3;
170
116
  }
171
117
 
172
- .btn-primary:disabled {
173
- background: #6c757d;
174
- cursor: not-allowed;
175
- }
176
-
177
118
  .btn-secondary {
178
119
  background: #6c757d;
179
120
  color: white;
@@ -183,239 +124,638 @@
183
124
  background: #545b62;
184
125
  }
185
126
 
186
- .btn-danger {
187
- background: #dc3545;
127
+ /* DML Warning Styles */
128
+ .btn-dml {
129
+ background-color: #ff6b6b;
188
130
  color: white;
189
- font-size: 12px;
190
- padding: 5px 10px;
131
+ border: none;
132
+ padding: 8px 16px;
133
+ border-radius: 4px;
134
+ cursor: pointer;
135
+ font-size: 14px;
136
+ font-weight: 500;
191
137
  }
192
138
 
193
- .btn-danger:hover {
194
- background: #c82333;
139
+ .btn-dml:hover {
140
+ background-color: #ff5252;
195
141
  }
196
142
 
197
- textarea {
143
+ .dml-warning {
144
+ background-color: #fff3cd;
145
+ border-left: 4px solid #ffc107;
146
+ padding: 12px 16px;
147
+ margin-bottom: 16px;
148
+ margin-top: 16px;
149
+ border-radius: 4px;
150
+ }
151
+
152
+ .dml-warning-icon {
153
+ color: #ff6b6b;
154
+ font-weight: bold;
155
+ margin-right: 8px;
156
+ }
157
+
158
+ /* SQL Editor (CodeMirror) */
159
+ .sql-editor-container {
198
160
  width: 100%;
199
161
  min-height: 200px;
200
- padding: 15px;
201
- border: 1px solid #ddd;
202
162
  border-radius: 4px;
203
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
163
+ overflow: hidden;
164
+ }
165
+
166
+ .sql-editor-container:focus-within {
167
+ box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
168
+ }
169
+
170
+ .cm-editor {
171
+ height: 100%;
172
+ }
173
+
174
+ .cm-scroller {
175
+ min-height: 200px;
176
+ font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
204
177
  font-size: 14px;
205
- resize: vertical;
206
- margin-bottom: 15px;
178
+ line-height: 1.5;
179
+ }
180
+
181
+ .cm-content {
182
+ padding: 12px;
183
+ }
184
+
185
+ .cm-focused {
186
+ outline: none !important;
187
+ }
188
+
189
+
190
+ /* Results Containers - prevent horizontal expansion */
191
+ turbo-frame#query-results,
192
+ turbo-frame#explain-results {
193
+ display: block;
194
+ width: 100%;
195
+ min-width: 0;
196
+ overflow-x: auto;
207
197
  }
208
198
 
209
- textarea:focus {
210
- outline: none;
211
- border-color: #007bff;
212
- box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
199
+ /* Right Panel */
200
+ .right-panel {
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: 20px;
213
204
  }
214
205
 
215
- .history-section {
206
+ /* Tabbed Section */
207
+ .tabbed-section {
216
208
  background: white;
217
209
  border-radius: 8px;
218
- padding: 20px;
219
210
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
220
- max-height: 800px;
221
- overflow-y: auto;
222
- transition: all 0.3s ease;
211
+ overflow: hidden;
223
212
  }
213
+
214
+ .tabbed-section.collapsed .tab-content { display: none; }
224
215
 
225
- .history-section.collapsed {
226
- padding: 15px 20px;
227
- max-height: none;
228
- overflow: visible;
216
+ .tabs {
217
+ display: flex;
218
+ border-bottom: 1px solid #dee2e6;
219
+ background: #f8f9fa;
229
220
  }
230
221
 
231
- .history-section.collapsed .history-content {
232
- display: none;
222
+ .tab {
223
+ padding: 12px 20px;
224
+ cursor: pointer;
225
+ border: none;
226
+ background: transparent;
227
+ border-bottom: 2px solid transparent;
228
+ font-size: 14px;
229
+ font-weight: 500;
233
230
  }
234
231
 
235
- .history-header {
236
- display: flex;
237
- justify-content: space-between;
238
- align-items: center;
239
- margin-bottom: 15px;
240
- position: relative;
232
+ .tab.active {
233
+ border-bottom-color: #007bff;
234
+ color: #007bff;
241
235
  }
242
236
 
243
- .history-section.collapsed .history-header {
244
- margin-bottom: 0;
237
+ .tab-content {
238
+ padding: 15px;
239
+ max-height: 400px;
240
+ overflow-y: auto;
245
241
  }
246
242
 
247
- .history-header h3 {
248
- margin: 0;
249
- font-size: 18px;
250
- flex-grow: 1;
243
+ .tab-pane {
244
+ display: none;
245
+ }
246
+
247
+ .tab-pane.active {
248
+ display: block;
251
249
  }
252
250
 
253
- .history-list {
251
+ /* History & Schema Lists */
252
+ ul.item-list {
254
253
  list-style: none;
255
254
  padding: 0;
256
255
  margin: 0;
257
256
  }
258
257
 
259
- .history-item {
260
- margin-bottom: 10px;
258
+ ul.item-list li {
259
+ padding: 10px;
260
+ border-bottom: 1px solid #eee;
261
+ cursor: pointer;
261
262
  }
262
263
 
263
- .history-item-button {
264
- width: 100%;
264
+ ul.item-list li:hover {
265
265
  background: #f8f9fa;
266
+ }
267
+
268
+ /* Saved Queries Section */
269
+ .saved-section {
270
+ background: white;
271
+ border-radius: 8px;
272
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
273
+ padding: 15px;
274
+ }
275
+
276
+ .saved-section.collapsed .saved-content {
277
+ display: none;
278
+ }
279
+
280
+ .saved-header {
281
+ display: flex;
282
+ justify-content: space-between;
283
+ align-items: center;
284
+ margin-bottom: 10px;
285
+ }
286
+
287
+ .saved-header h4 {
288
+ margin: 0;
289
+ font-size: 14px;
290
+ }
291
+
292
+ .saved-query-item {
293
+ padding: 10px;
266
294
  border: 1px solid #dee2e6;
267
295
  border-radius: 4px;
268
- padding: 10px;
269
- text-align: left;
296
+ margin-bottom: 8px;
297
+ }
298
+
299
+ .saved-query-item:hover {
300
+ background: #f8f9fa;
301
+ }
302
+
303
+ /* Schema Explorer */
304
+ .schema-search {
305
+ width: 100%;
306
+ padding: 8px;
307
+ border: 1px solid #ddd;
308
+ border-radius: 4px;
309
+ margin-bottom: 10px;
310
+ }
311
+
312
+ .table-item {
313
+ padding: 8px;
270
314
  cursor: pointer;
271
- transition: all 0.2s;
315
+ border-bottom: 1px solid #eee;
272
316
  }
273
317
 
274
- .history-item-button:hover {
275
- background: #e9ecef;
276
- border-color: #007bff;
277
- transform: translateX(2px);
318
+ .table-item:hover {
319
+ background: #f8f9fa;
278
320
  }
279
321
 
280
- .history-item-sql {
281
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
282
- font-size: 12px;
283
- color: #495057;
284
- margin-bottom: 5px;
285
- white-space: nowrap;
286
- overflow: hidden;
287
- text-overflow: ellipsis;
322
+ .columns-list {
323
+ margin-top: 10px;
324
+ padding-left: 15px;
325
+ }
326
+
327
+ .column-item {
328
+ padding: 6px;
329
+ font-size: 13px;
330
+ border-left: 2px solid #007bff;
331
+ margin-bottom: 4px;
332
+ background: #f8f9fa;
333
+ }
334
+
335
+ /* Quick Actions */
336
+ .quick-actions {
337
+ display: flex;
338
+ gap: 6px;
339
+ margin-top: 6px;
288
340
  }
289
341
 
290
- .history-item-time {
342
+ .quick-action-btn {
343
+ padding: 4px 8px;
291
344
  font-size: 11px;
292
- color: #6c757d;
345
+ background: #e9ecef;
346
+ border: 1px solid #dee2e6;
347
+ border-radius: 3px;
348
+ cursor: pointer;
293
349
  }
294
350
 
295
- .empty-history {
296
- color: #6c757d;
297
- font-style: italic;
298
- text-align: center;
299
- padding: 20px;
351
+ .quick-action-btn:hover {
352
+ background: #dee2e6;
300
353
  }
301
354
 
302
- @media (max-width: 768px) {
303
- .main-layout {
304
- grid-template-columns: 1fr;
305
- }
355
+ /* Results */
356
+ .results-container {
357
+ margin-top: 20px;
306
358
  }
307
- </style>
308
- </head>
309
- <body>
310
- <div class="container">
311
- <!-- Security Banner - Collapsible -->
312
- <div class="banner" id="security-banner" data-controller="collapsible" data-collapsible-key-value="banner">
313
- <button class="banner-toggle" data-action="click->collapsible#toggle" title="Toggle banner">▼</button>
314
- <h2>🔍 Read-Only SQL Query Console</h2>
315
- <div class="banner-content">
316
- <p><strong>Security Notice:</strong> This console is enabled only in development by default.</p>
317
- <p>All queries are logged and restricted to read-only operations (SELECT statements only).</p>
318
- <p>Multiple statements and write operations are blocked for security.</p>
319
- </div>
320
- </div>
321
359
 
322
- <div class="main-layout">
323
- <!-- SQL Editor Section - Collapsible -->
324
- <%= form_with url: query_console.run_path, method: :post, data: {
325
- turbo_frame: "query-results",
326
- controller: "editor",
327
- action: "turbo:submit-end->editor#querySuccess turbo:frame-render->editor#querySuccess submit->editor#handleSubmit"
328
- } do |f| %>
329
- <div class="editor-section" id="editor-section" data-controller="collapsible" data-collapsible-key-value="editor">
330
- <div class="editor-header">
331
- <h3>SQL Editor</h3>
332
- <div class="button-group">
333
- <button type="button" data-action="click->editor#clear" data-editor-target="clearButton" class="btn-secondary">Clear</button>
334
- <%= f.submit "Run Query", class: "btn-primary", data: { editor_target: "runButton" } %>
335
- </div>
336
- <button type="button" class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor">▼</button>
337
- </div>
360
+ .table-wrapper {
361
+ overflow: auto;
362
+ border: 1px solid #dee2e6;
363
+ border-radius: 4px;
364
+ max-height: 500px;
365
+ }
338
366
 
339
- <div class="editor-content">
340
- <%= f.text_area :sql,
341
- placeholder: "Enter your SELECT query here...\n\nExample:\nSELECT * FROM users LIMIT 10;",
342
- data: { editor_target: "textarea" } %>
367
+ .results-table {
368
+ width: 100%;
369
+ border-collapse: collapse;
370
+ }
343
371
 
344
- <!-- Turbo Frame for Results -->
345
- <%= turbo_frame_tag "query-results", data: { editor_target: "results" } do %>
346
- <div style="color: #6c757d; text-align: center; padding: 40px;">
347
- <p>Enter a query above and click "Run Query" to see results here.</p>
348
- </div>
349
- <% end %>
350
- </div>
351
- </div>
352
- <% end %>
353
-
354
- <!-- Query History Section - Collapsible -->
355
- <div class="history-section" id="history-section" data-controller="history collapsible" data-collapsible-key-value="history_section">
356
- <div class="history-header">
357
- <h3>Query History</h3>
358
- <button data-action="click->history#clear" class="btn-danger">Clear</button>
359
- <button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle history">▼</button>
360
- </div>
361
- <div class="history-content">
362
- <ul class="history-list" data-history-target="list">
363
- <li class="empty-history">No query history yet</li>
364
- </ul>
365
- </div>
366
- </div>
367
- </div>
368
- </div>
372
+ .results-table thead {
373
+ position: sticky;
374
+ top: 0;
375
+ background: #f8f9fa;
376
+ z-index: 10;
377
+ }
378
+
379
+ .results-table th,
380
+ .results-table td {
381
+ padding: 8px 12px;
382
+ text-align: left;
383
+ border: 1px solid #dee2e6;
384
+ white-space: nowrap;
385
+ }
386
+
387
+ .results-table th {
388
+ font-weight: 600;
389
+ background: #e9ecef;
390
+ }
391
+ </style>
369
392
 
370
- <!-- Load Hotwire and Stimulus from CDN -->
371
393
  <script type="importmap">
372
394
  {
373
395
  "imports": {
374
396
  "@hotwired/turbo-rails": "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.min.js",
375
- "@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.min.js"
397
+ "@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.min.js",
398
+ "codemirror": "https://esm.sh/codemirror@6.0.1",
399
+ "@codemirror/lang-sql": "https://esm.sh/@codemirror/lang-sql@6.6.0"
376
400
  }
377
401
  }
378
402
  </script>
379
403
 
380
- <!-- Load Stimulus controllers inline -->
381
404
  <script type="module">
382
405
  import * as Turbo from "@hotwired/turbo-rails"
383
406
  import { Application, Controller } from "@hotwired/stimulus"
384
-
385
- // Make Turbo available globally
386
- window.Turbo = Turbo
387
-
407
+ import { EditorView, basicSetup } from "codemirror"
408
+ import { sql } from "@codemirror/lang-sql"
409
+
388
410
  const application = Application.start()
389
- window.Stimulus = application
390
-
411
+
391
412
  // Collapsible Controller
392
413
  class CollapsibleController extends Controller {
393
414
  static values = { key: String }
394
415
 
395
416
  connect() {
396
- this.storageKey = `query_console.${this.keyValue}_collapsed`
397
417
  this.loadState()
398
418
  }
399
419
 
400
- toggle(event) {
401
- event.preventDefault()
420
+ toggle() {
402
421
  this.element.classList.toggle('collapsed')
403
-
404
- const isCollapsed = this.element.classList.contains('collapsed')
405
- localStorage.setItem(this.storageKey, isCollapsed ? 'true' : 'false')
406
- event.target.textContent = isCollapsed ? '▲' : '▼'
422
+ this.saveState()
407
423
  }
408
424
 
409
425
  loadState() {
410
- const isCollapsed = localStorage.getItem(this.storageKey) === 'true'
411
- if (isCollapsed) {
426
+ const key = this.keyValue
427
+ const collapsed = localStorage.getItem(`qc.collapsed.${key}`) === 'true'
428
+ if (collapsed) {
412
429
  this.element.classList.add('collapsed')
413
- const button = this.element.querySelector('.section-toggle, .banner-toggle')
414
- if (button) button.textContent = '▲'
415
430
  }
416
431
  }
432
+
433
+ saveState() {
434
+ const key = this.keyValue
435
+ const collapsed = this.element.classList.contains('collapsed')
436
+ localStorage.setItem(`qc.collapsed.${key}`, collapsed)
437
+ }
417
438
  }
418
-
439
+ application.register("collapsible", CollapsibleController)
440
+
441
+ // Tabs Controller
442
+ class TabsController extends Controller {
443
+ static targets = ["tab", "pane"]
444
+
445
+ connect() {
446
+ this.showTab(0)
447
+ }
448
+
449
+ select(event) {
450
+ const index = this.tabTargets.indexOf(event.currentTarget)
451
+ this.showTab(index)
452
+ }
453
+
454
+ showTab(index) {
455
+ this.tabTargets.forEach((tab, i) => {
456
+ tab.classList.toggle('active', i === index)
457
+ })
458
+ this.paneTargets.forEach((pane, i) => {
459
+ pane.classList.toggle('active', i === index)
460
+ })
461
+ }
462
+ }
463
+ application.register("tabs", TabsController)
464
+
465
+ // Editor Controller (CodeMirror)
466
+ class EditorController extends Controller {
467
+ static targets = ["container"]
468
+
469
+ connect() {
470
+ this.initializeCodeMirror()
471
+ }
472
+
473
+ disconnect() {
474
+ if (this.view) {
475
+ this.view.destroy()
476
+ }
477
+ }
478
+
479
+ initializeCodeMirror() {
480
+ try {
481
+ this.view = new EditorView({
482
+ doc: "SELECT * FROM users LIMIT 10;",
483
+ extensions: [
484
+ basicSetup,
485
+ sql(),
486
+ EditorView.lineWrapping,
487
+ EditorView.theme({
488
+ "&": {
489
+ fontSize: "14px"
490
+ },
491
+ ".cm-content": {
492
+ fontFamily: "'Monaco', 'Menlo', 'Courier New', monospace",
493
+ minHeight: "200px",
494
+ padding: "12px"
495
+ },
496
+ ".cm-scroller": {
497
+ overflow: "auto"
498
+ },
499
+ "&.cm-focused": {
500
+ outline: "none"
501
+ }
502
+ })
503
+ ],
504
+ parent: this.containerTarget
505
+ })
506
+ } catch (error) {
507
+ console.error('CodeMirror initialization error:', error)
508
+ // Fallback to simple textarea
509
+ this.containerTarget.innerHTML = '<textarea class="sql-editor" style="width:100%; min-height:200px; font-family:monospace; padding:12px;">SELECT * FROM users LIMIT 10;</textarea>'
510
+ }
511
+ }
512
+
513
+ getSql() {
514
+ return this.view.state.doc.toString()
515
+ }
516
+
517
+ setSql(text) {
518
+ this.view.dispatch({
519
+ changes: {
520
+ from: 0,
521
+ to: this.view.state.doc.length,
522
+ insert: text
523
+ }
524
+ })
525
+ this.view.focus()
526
+ }
527
+
528
+ insertAtCursor(text) {
529
+ const selection = this.view.state.selection.main
530
+ this.view.dispatch({
531
+ changes: {
532
+ from: selection.from,
533
+ to: selection.to,
534
+ insert: text
535
+ },
536
+ selection: {
537
+ anchor: selection.from + text.length
538
+ }
539
+ })
540
+ this.view.focus()
541
+ }
542
+
543
+ clearEditor() {
544
+ this.setSql('')
545
+
546
+ // Clear query results
547
+ const queryFrame = document.querySelector('turbo-frame#query-results')
548
+ if (queryFrame) {
549
+ queryFrame.innerHTML = '<div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;"><p>Enter a query above and click "Run Query" to see results here.</p></div>'
550
+ }
551
+
552
+ // Clear explain results
553
+ const explainFrame = document.querySelector('turbo-frame#explain-results')
554
+ if (explainFrame) {
555
+ explainFrame.innerHTML = ''
556
+ }
557
+ }
558
+
559
+ isDmlQuery(sql) {
560
+ const trimmed = sql.trim().toLowerCase()
561
+ return /^(insert|update|delete|merge)\b/.test(trimmed)
562
+ }
563
+
564
+ runQuery() {
565
+ const sql = this.getSql().trim()
566
+ if (!sql) {
567
+ alert('Please enter a SQL query')
568
+ return
569
+ }
570
+
571
+ // Check if it's a DML query and confirm with user
572
+ if (this.isDmlQuery(sql)) {
573
+ const confirmed = confirm(
574
+ '⚠️ DATA MODIFICATION WARNING\n\n' +
575
+ 'This query will INSERT, UPDATE, or DELETE data.\n\n' +
576
+ '• All changes are PERMANENT and cannot be undone\n' +
577
+ '• All operations are logged\n\n' +
578
+ 'Do you want to proceed?'
579
+ )
580
+
581
+ if (!confirmed) {
582
+ return // User cancelled
583
+ }
584
+ }
585
+
586
+ // Clear explain results when running query
587
+ const explainFrame = document.querySelector('turbo-frame#explain-results')
588
+ if (explainFrame) {
589
+ explainFrame.innerHTML = ''
590
+ }
591
+
592
+ // Store for history
593
+ window._lastExecutedSQL = sql
594
+
595
+ // Create form with Turbo Frame target
596
+ const form = document.createElement('form')
597
+ form.method = 'POST'
598
+ form.action = '<%= query_console.run_path %>'
599
+ form.setAttribute('data-turbo-frame', 'query-results')
600
+ form.innerHTML = `
601
+ <input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
602
+ <input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
603
+ `
604
+ document.body.appendChild(form)
605
+ form.requestSubmit()
606
+ document.body.removeChild(form)
607
+ }
608
+
609
+ explainQuery() {
610
+ const sql = this.getSql().trim()
611
+ if (!sql) {
612
+ alert('Please enter a SQL query')
613
+ return
614
+ }
615
+
616
+ // Clear query results when running explain
617
+ const queryFrame = document.querySelector('turbo-frame#query-results')
618
+ if (queryFrame) {
619
+ queryFrame.innerHTML = ''
620
+ }
621
+
622
+ // Create form with Turbo Frame target
623
+ const form = document.createElement('form')
624
+ form.method = 'POST'
625
+ form.action = '<%= query_console.explain_path %>'
626
+ form.setAttribute('data-turbo-frame', 'explain-results')
627
+ form.innerHTML = `
628
+ <input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
629
+ <input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
630
+ `
631
+ document.body.appendChild(form)
632
+ form.requestSubmit()
633
+ document.body.removeChild(form)
634
+ }
635
+
636
+ escapeHtml(text) {
637
+ const div = document.createElement('div')
638
+ div.textContent = text
639
+ return div.innerHTML
640
+ }
641
+ }
642
+ application.register("editor", EditorController)
643
+
644
+ // Schema Controller
645
+ class SchemaController extends Controller {
646
+ static targets = ["search", "tablesList", "details"]
647
+
648
+ connect() {
649
+ this.loadTables()
650
+ }
651
+
652
+ async loadTables() {
653
+ try {
654
+ const response = await fetch('<%= query_console.schema_tables_path %>')
655
+ this.tables = await response.json()
656
+ this.renderTables()
657
+ } catch (error) {
658
+ console.error('Failed to load tables:', error)
659
+ }
660
+ }
661
+
662
+ filterTables(event) {
663
+ const query = event.target.value.toLowerCase()
664
+ this.renderTables(query)
665
+ }
666
+
667
+ renderTables(filter = '') {
668
+ const filtered = filter ?
669
+ this.tables.filter(t => t.name.toLowerCase().includes(filter)) :
670
+ this.tables
671
+
672
+ this.tablesListTarget.innerHTML = filtered.map(table =>
673
+ `<div class="table-item" data-action="click->schema#selectTable" data-table-name="${table.name}">
674
+ 📊 ${table.name} <small>(${table.kind})</small>
675
+ </div>`
676
+ ).join('')
677
+ }
678
+
679
+ async selectTable(event) {
680
+ const tableName = event.currentTarget.dataset.tableName
681
+
682
+ try {
683
+ const response = await fetch(`<%= query_console.schema_tables_path %>/${tableName}`)
684
+ const tableData = await response.json()
685
+ this.renderTableDetails(tableData)
686
+ } catch (error) {
687
+ console.error('Failed to load table details:', error)
688
+ }
689
+ }
690
+
691
+ renderTableDetails(table) {
692
+ const editor = this.application.getControllerForElementAndIdentifier(
693
+ document.querySelector('[data-controller="editor"]'),
694
+ 'editor'
695
+ )
696
+
697
+ this.detailsTarget.innerHTML = `
698
+ <h5>${table.name}</h5>
699
+ <div class="quick-actions">
700
+ <button class="quick-action-btn" data-action="click->schema#insertSelectAll" data-table="${table.name}">
701
+ SELECT * FROM ${table.name}
702
+ </button>
703
+ <button class="quick-action-btn" data-action="click->schema#copyTableName" data-table="${table.name}">
704
+ 📋 Copy Table Name
705
+ </button>
706
+ </div>
707
+ <div class="columns-list">
708
+ ${table.columns.map(col => `
709
+ <div class="column-item">
710
+ <strong>${col.name}</strong> <code>${col.db_type}</code>
711
+ ${col.nullable ? '<span>NULL</span>' : '<span>NOT NULL</span>'}
712
+ <div class="quick-actions">
713
+ <button class="quick-action-btn" data-action="click->schema#insertColumn" data-column="${col.name}">
714
+ Insert
715
+ </button>
716
+ <button class="quick-action-btn" data-action="click->schema#insertWhere" data-column="${col.name}">
717
+ WHERE
718
+ </button>
719
+ </div>
720
+ </div>
721
+ `).join('')}
722
+ </div>
723
+ `
724
+ }
725
+
726
+ insertSelectAll(event) {
727
+ const table = event.currentTarget.dataset.table
728
+ const editor = this.getEditor()
729
+ editor.setSql(`SELECT * FROM ${table} LIMIT 100;`)
730
+ }
731
+
732
+ insertColumn(event) {
733
+ const column = event.currentTarget.dataset.column
734
+ const editor = this.getEditor()
735
+ editor.insertAtCursor(column)
736
+ }
737
+
738
+ insertWhere(event) {
739
+ const column = event.currentTarget.dataset.column
740
+ const editor = this.getEditor()
741
+ editor.insertAtCursor(`WHERE ${column} = `)
742
+ }
743
+
744
+ copyTableName(event) {
745
+ const table = event.currentTarget.dataset.table
746
+ navigator.clipboard.writeText(table)
747
+ alert(`Copied: ${table}`)
748
+ }
749
+
750
+ getEditor() {
751
+ return this.application.getControllerForElementAndIdentifier(
752
+ document.querySelector('[data-controller~="editor"]'),
753
+ 'editor'
754
+ )
755
+ }
756
+ }
757
+ application.register("schema", SchemaController)
758
+
419
759
  // History Controller
420
760
  class HistoryController extends Controller {
421
761
  static targets = ["list"]
@@ -425,38 +765,60 @@
425
765
  }
426
766
 
427
767
  connect() {
428
- this.loadHistory()
429
- document.addEventListener('editor:executed', (e) => this.add(e))
768
+ this.render()
769
+ document.addEventListener('editor:executed', (e) => this.addQuery(e.detail.sql))
430
770
  }
431
771
 
432
- add(event) {
772
+ addQuery(sql) {
433
773
  const history = this.getHistory()
434
774
  history.unshift({
435
- sql: event.detail.sql.trim(),
436
- timestamp: event.detail.timestamp
775
+ sql: sql,
776
+ timestamp: new Date().toISOString()
437
777
  })
438
778
 
439
- const trimmed = history.slice(0, this.maxItemsValue)
440
- localStorage.setItem(this.storageKeyValue, JSON.stringify(trimmed))
441
- this.renderHistory(trimmed)
779
+ if (history.length > this.maxItemsValue) {
780
+ history.pop()
781
+ }
782
+
783
+ this.saveHistory(history)
784
+ this.render()
442
785
  }
443
786
 
444
- load(event) {
445
- event.preventDefault()
446
- const sql = event.currentTarget.dataset.sql
447
- document.dispatchEvent(new CustomEvent('history:load', { detail: { sql } }))
787
+ render() {
788
+ const history = this.getHistory()
789
+ this.listTarget.innerHTML = history.length ?
790
+ history.map((item, index) => `
791
+ <li data-action="click->history#load" data-index="${index}">
792
+ <div style="font-size: 12px; color: #6c757d;">${new Date(item.timestamp).toLocaleString()}</div>
793
+ <div style="font-size: 13px; margin-top: 4px;">${this.truncate(item.sql, 100)}</div>
794
+ </li>
795
+ `).join('') :
796
+ '<li style="color: #6c757d; text-align: center; padding: 20px;">No query history</li>'
448
797
  }
449
798
 
450
- clear(event) {
451
- event.preventDefault()
452
- if (confirm("Clear all query history?")) {
453
- localStorage.removeItem(this.storageKeyValue)
454
- this.renderHistory([])
799
+ load(event) {
800
+ const index = parseInt(event.currentTarget.dataset.index)
801
+ const history = this.getHistory()
802
+ const query = history[index]
803
+
804
+ if (query) {
805
+ const editor = this.getEditor()
806
+ editor.setSql(query.sql)
455
807
  }
456
808
  }
457
809
 
458
- loadHistory() {
459
- this.renderHistory(this.getHistory())
810
+ getEditor() {
811
+ return this.application.getControllerForElementAndIdentifier(
812
+ document.querySelector('[data-controller~="editor"]'),
813
+ 'editor'
814
+ )
815
+ }
816
+
817
+ clear() {
818
+ if (confirm('Clear all query history?')) {
819
+ localStorage.removeItem(this.storageKeyValue)
820
+ this.render()
821
+ }
460
822
  }
461
823
 
462
824
  getHistory() {
@@ -464,102 +826,255 @@
464
826
  return stored ? JSON.parse(stored) : []
465
827
  }
466
828
 
467
- renderHistory(history) {
468
- if (history.length === 0) {
469
- this.listTarget.innerHTML = '<li class="empty-history">No query history yet</li>'
470
- return
471
- }
472
-
473
- this.listTarget.innerHTML = history.map(item => `
474
- <li class="history-item">
475
- <button type="button" class="history-item-button"
476
- data-action="click->history#load"
477
- data-sql="${this.escapeHtml(item.sql)}">
478
- <div class="history-item-sql">${this.escapeHtml(this.truncate(item.sql, 100))}</div>
479
- <div class="history-item-time">${this.formatTime(item.timestamp)}</div>
480
- </button>
481
- </li>
482
- `).join('')
829
+ saveHistory(history) {
830
+ localStorage.setItem(this.storageKeyValue, JSON.stringify(history))
483
831
  }
484
832
 
485
833
  truncate(str, length) {
486
834
  return str.length > length ? str.substring(0, length) + '...' : str
487
835
  }
488
-
489
- formatTime(timestamp) {
490
- const date = new Date(timestamp)
491
- const diff = Date.now() - date
492
- if (diff < 60000) return 'just now'
493
- if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`
494
- if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`
495
- return date.toLocaleDateString()
496
- }
497
-
498
- escapeHtml(text) {
499
- const div = document.createElement('div')
500
- div.textContent = text
501
- return div.innerHTML
502
- }
503
836
  }
504
-
505
- // Editor Controller
506
- class EditorController extends Controller {
507
- static targets = ["textarea", "runButton"]
508
-
509
- connect() {
510
- document.addEventListener('history:load', (e) => this.loadQuery(e.detail.sql))
511
- }
512
-
513
- loadQuery(sql) {
514
- this.textareaTarget.value = sql
515
- this.textareaTarget.focus()
516
- this.element.scrollIntoView({ behavior: 'smooth' })
837
+ application.register("history", HistoryController)
838
+
839
+ // Saved Queries Controller
840
+ class SavedController extends Controller {
841
+ static targets = ["list"]
842
+ static values = {
843
+ storageKey: { type: String, default: "query_console.saved.v1" }
517
844
  }
518
845
 
519
- clear(event) {
520
- event.preventDefault()
521
- this.textareaTarget.value = ''
522
- this.textareaTarget.focus()
846
+ connect() {
847
+ this.render()
523
848
  }
524
849
 
525
- handleSubmit(event) {
526
- const sql = this.textareaTarget.value.trim()
850
+ save() {
851
+ const editor = this.getEditor()
852
+ const sql = editor.getSql()
527
853
 
528
- if (!sql) {
529
- event.preventDefault()
530
- alert('Please enter a SQL query')
854
+ if (!sql.trim()) {
855
+ alert('Nothing to save')
531
856
  return
532
857
  }
533
858
 
534
- // Store SQL for after execution
535
- window._lastExecutedSQL = sql
859
+ const name = prompt('Query name:')
860
+ if (!name) return
536
861
 
537
- // Show loading state
538
- this.runButtonTarget.disabled = true
539
- this.runButtonTarget.value = 'Running...'
862
+ const tags = prompt('Tags (comma-separated, optional):')
540
863
 
541
- // Let form submit naturally (Turbo will intercept it)
864
+ const saved = this.getSaved()
865
+ saved.push({
866
+ id: Date.now().toString(),
867
+ name: name,
868
+ tags: tags ? tags.split(',').map(t => t.trim()) : [],
869
+ sql: sql,
870
+ createdAt: new Date().toISOString(),
871
+ updatedAt: new Date().toISOString()
872
+ })
873
+
874
+ this.saveSaved(saved)
875
+ this.render()
542
876
  }
543
877
 
544
- querySuccess() {
545
- this.runButtonTarget.disabled = false
546
- this.runButtonTarget.value = 'Run Query'
878
+ load(event) {
879
+ const id = event.currentTarget.dataset.id
880
+ const saved = this.getSaved()
881
+ const query = saved.find(q => q.id === id)
547
882
 
548
- if (window._lastExecutedSQL) {
549
- document.dispatchEvent(new CustomEvent('editor:executed', {
550
- detail: {
551
- sql: window._lastExecutedSQL,
552
- timestamp: new Date().toISOString()
553
- }
554
- }))
555
- delete window._lastExecutedSQL
883
+ if (query) {
884
+ const editor = this.getEditor()
885
+ editor.setSql(query.sql)
556
886
  }
557
887
  }
888
+
889
+ delete(event) {
890
+ if (!confirm('Delete this saved query?')) return
891
+
892
+ const id = event.currentTarget.dataset.id
893
+ const saved = this.getSaved().filter(q => q.id !== id)
894
+ this.saveSaved(saved)
895
+ this.render()
896
+ }
897
+
898
+ exportJSON() {
899
+ const saved = this.getSaved()
900
+ const json = JSON.stringify(saved, null, 2)
901
+ navigator.clipboard.writeText(json)
902
+ alert('Saved queries copied to clipboard!')
903
+ }
904
+
905
+ importJSON() {
906
+ const json = prompt('Paste saved queries JSON:')
907
+ if (!json) return
908
+
909
+ try {
910
+ const imported = JSON.parse(json)
911
+ const saved = this.getSaved()
912
+ this.saveSaved([...saved, ...imported])
913
+ this.render()
914
+ alert(`Imported ${imported.length} queries`)
915
+ } catch (error) {
916
+ alert('Invalid JSON: ' + error.message)
917
+ }
918
+ }
919
+
920
+ render() {
921
+ const saved = this.getSaved()
922
+ this.listTarget.innerHTML = saved.length ?
923
+ saved.map(query => `
924
+ <div class="saved-query-item">
925
+ <strong>${query.name}</strong>
926
+ ${query.tags.length ? `<div><small>🏷 ${query.tags.join(', ')}</small></div>` : ''}
927
+ <div style="font-size: 12px; color: #6c757d; margin: 4px 0;">
928
+ ${new Date(query.updatedAt).toLocaleString()}
929
+ </div>
930
+ <div class="quick-actions">
931
+ <button class="quick-action-btn" data-action="click->saved#load" data-id="${query.id}">Load</button>
932
+ <button class="quick-action-btn" data-action="click->saved#delete" data-id="${query.id}">Delete</button>
933
+ </div>
934
+ </div>
935
+ `).join('') :
936
+ '<div style="color: #6c757d; text-align: center; padding: 20px;">No saved queries</div>'
937
+ }
938
+
939
+ getSaved() {
940
+ const stored = localStorage.getItem(this.storageKeyValue)
941
+ return stored ? JSON.parse(stored) : []
942
+ }
943
+
944
+ saveSaved(saved) {
945
+ localStorage.setItem(this.storageKeyValue, JSON.stringify(saved))
946
+ }
947
+
948
+ getEditor() {
949
+ return this.application.getControllerForElementAndIdentifier(
950
+ document.querySelector('[data-controller~="editor"]'),
951
+ 'editor'
952
+ )
953
+ }
558
954
  }
559
-
560
- application.register("collapsible", CollapsibleController)
561
- application.register("history", HistoryController)
562
- application.register("editor", EditorController)
955
+ application.register("saved", SavedController)
956
+ </script>
957
+ </head>
958
+ <body>
959
+ <div class="container">
960
+ <!-- Banner -->
961
+ <div class="banner" data-controller="collapsible" data-collapsible-key-value="banner">
962
+ <h2>🔍 <% if QueryConsole.configuration.enable_dml %>SQL Query Console<% else %>Read-Only SQL Query Console<% end %> <small>v0.2.0</small></h2>
963
+ <div class="banner-content">
964
+ <p>
965
+ <strong>Security:</strong>
966
+ <% if QueryConsole.configuration.enable_dml %>
967
+ DML queries (INSERT, UPDATE, DELETE) are enabled. All queries are logged. Use with caution.
968
+ <% else %>
969
+ Read-only SELECT & WITH queries only. All queries are logged.
970
+ <% end %>
971
+ </p>
972
+ <p><strong>New in v0.2.0:</strong> EXPLAIN query plans, Interactive Schema Explorer with quick insert buttons, Saved Queries with tags, import/export, and localStorage-based Query History!</p>
973
+ </div>
974
+ <button class="section-toggle" data-action="click->collapsible#toggle" type="button">▼</button>
975
+ </div>
976
+
977
+ <div class="main-layout">
978
+ <!-- Editor Section -->
979
+ <div class="editor-section" data-controller="editor collapsible" data-collapsible-key-value="editor_section">
980
+ <div class="editor-header">
981
+ <h3>SQL Editor</h3>
982
+ <div class="button-group">
983
+ <button class="btn-secondary" data-action="click->editor#clearEditor" type="button">Clear</button>
984
+ <button class="btn-secondary" data-action="click->editor#explainQuery" type="button">⚡ Explain</button>
985
+ <button class="btn-primary" data-action="click->editor#runQuery" type="button">▶ Run Query</button>
986
+ </div>
987
+ <button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor" type="button">▼</button>
988
+ </div>
989
+
990
+ <div class="editor-content" data-collapsible-target="content">
991
+ <!-- SQL Editor (CodeMirror) -->
992
+ <div data-editor-target="container" class="sql-editor-container"></div>
993
+
994
+ <!-- Results Area -->
995
+ <%= turbo_frame_tag "query-results" do %>
996
+ <div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;">
997
+ <p>Enter a query above and click "Run Query" to see results here.</p>
998
+ </div>
999
+ <% end %>
1000
+
1001
+ <!-- Explain Results Area -->
1002
+ <%= turbo_frame_tag "explain-results" do %>
1003
+ <% end %>
1004
+ </div>
1005
+ </div>
1006
+
1007
+ <!-- Right Panel -->
1008
+ <div class="right-panel">
1009
+ <!-- Tabbed Section (History / Schema / Saved Queries) -->
1010
+ <div class="tabbed-section" data-controller="tabs collapsible" data-collapsible-key-value="tabs_section">
1011
+ <div class="tabs" style="position: relative;">
1012
+ <button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📜 History</button>
1013
+ <button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📊 Schema</button>
1014
+ <button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">💾 Saved</button>
1015
+ <button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle tabs" type="button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; padding: 4px 8px; font-size: 14px;">▼</button>
1016
+ </div>
1017
+
1018
+ <div class="tab-content" data-collapsible-target="content">
1019
+ <!-- History Tab -->
1020
+ <div class="tab-pane" data-tabs-target="pane">
1021
+ <div data-controller="history">
1022
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
1023
+ <h4 style="margin: 0; font-size: 14px;">Recent Queries</h4>
1024
+ <button class="quick-action-btn" data-action="click->history#clear" type="button">Clear</button>
1025
+ </div>
1026
+ <ul class="item-list" data-history-target="list"></ul>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ <!-- Schema Tab -->
1031
+ <div class="tab-pane" data-tabs-target="pane">
1032
+ <div data-controller="schema">
1033
+ <input
1034
+ type="text"
1035
+ class="schema-search"
1036
+ placeholder="🔍 Search tables..."
1037
+ data-schema-target="search"
1038
+ data-action="input->schema#filterTables">
1039
+ <div data-schema-target="tablesList"></div>
1040
+ <div data-schema-target="details" style="margin-top: 15px;"></div>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ <!-- Saved Queries Tab -->
1045
+ <div class="tab-pane" data-tabs-target="pane">
1046
+ <div data-controller="saved">
1047
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
1048
+ <h4 style="margin: 0; font-size: 14px;">Saved Queries</h4>
1049
+ <div style="display: flex; gap: 5px;">
1050
+ <button class="quick-action-btn" data-action="click->saved#save" type="button">💾 Save</button>
1051
+ <button class="quick-action-btn" data-action="click->saved#exportJSON" type="button">📤 Export</button>
1052
+ <button class="quick-action-btn" data-action="click->saved#importJSON" type="button">📥 Import</button>
1053
+ </div>
1054
+ </div>
1055
+ <div data-saved-target="list"></div>
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+ </div>
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <script>
1066
+ // Track query execution for history
1067
+ document.addEventListener('turbo:submit-end', (event) => {
1068
+ if (window._lastExecutedSQL) {
1069
+ document.dispatchEvent(new CustomEvent('editor:executed', {
1070
+ detail: {
1071
+ sql: window._lastExecutedSQL,
1072
+ timestamp: new Date().toISOString()
1073
+ }
1074
+ }))
1075
+ delete window._lastExecutedSQL
1076
+ }
1077
+ })
563
1078
  </script>
564
1079
  </body>
565
1080
  </html>