sqlshell 0.1.6__py3-none-any.whl → 0.1.9__py3-none-any.whl

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.

Potentially problematic release.


This version of sqlshell might be problematic. Click here for more details.

sqlshell/main.py CHANGED
@@ -1,500 +1,152 @@
1
1
  import sys
2
2
  import os
3
- import duckdb
4
- import sqlite3
3
+ import json
4
+
5
+ # Ensure proper path setup for resources when running directly
6
+ if __name__ == "__main__":
7
+ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8
+ sys.path.insert(0, project_root)
9
+
5
10
  import pandas as pd
6
11
  from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
7
12
  QHBoxLayout, QTextEdit, QPushButton, QFileDialog,
8
13
  QLabel, QSplitter, QListWidget, QTableWidget,
9
14
  QTableWidgetItem, QHeaderView, QMessageBox, QPlainTextEdit,
10
15
  QCompleter, QFrame, QToolButton, QSizePolicy, QTabWidget,
11
- QStyleFactory, QToolBar, QStatusBar)
12
- from PyQt6.QtCore import Qt, QAbstractTableModel, QRegularExpression, QRect, QSize, QStringListModel, QPropertyAnimation, QEasingCurve
13
- from PyQt6.QtGui import QFont, QColor, QSyntaxHighlighter, QTextCharFormat, QPainter, QTextFormat, QTextCursor, QIcon, QPalette, QLinearGradient, QBrush, QPixmap
16
+ QStyleFactory, QToolBar, QStatusBar, QLineEdit, QMenu,
17
+ QCheckBox, QWidgetAction, QMenuBar, QInputDialog, QProgressDialog,
18
+ QListWidgetItem)
19
+ from PyQt6.QtCore import Qt, QAbstractTableModel, QRegularExpression, QRect, QSize, QStringListModel, QPropertyAnimation, QEasingCurve, QTimer, QPoint, QMimeData
20
+ from PyQt6.QtGui import QFont, QColor, QSyntaxHighlighter, QTextCharFormat, QPainter, QTextFormat, QTextCursor, QIcon, QPalette, QLinearGradient, QBrush, QPixmap, QPolygon, QPainterPath, QDrag
14
21
  import numpy as np
15
22
  from datetime import datetime
16
- from sqlshell.sqlshell import create_test_data # Import from the correct location
17
-
18
- class SQLSyntaxHighlighter(QSyntaxHighlighter):
19
- def __init__(self, document):
20
- super().__init__(document)
21
- self.highlighting_rules = []
22
-
23
- # SQL Keywords
24
- keyword_format = QTextCharFormat()
25
- keyword_format.setForeground(QColor("#0000FF")) # Blue
26
- keyword_format.setFontWeight(QFont.Weight.Bold)
27
- keywords = [
28
- "\\bSELECT\\b", "\\bFROM\\b", "\\bWHERE\\b", "\\bAND\\b", "\\bOR\\b",
29
- "\\bINNER\\b", "\\bOUTER\\b", "\\bLEFT\\b", "\\bRIGHT\\b", "\\bJOIN\\b",
30
- "\\bON\\b", "\\bGROUP\\b", "\\bBY\\b", "\\bHAVING\\b", "\\bORDER\\b",
31
- "\\bLIMIT\\b", "\\bOFFSET\\b", "\\bUNION\\b", "\\bEXCEPT\\b", "\\bINTERSECT\\b",
32
- "\\bCREATE\\b", "\\bTABLE\\b", "\\bINDEX\\b", "\\bVIEW\\b", "\\bINSERT\\b",
33
- "\\bINTO\\b", "\\bVALUES\\b", "\\bUPDATE\\b", "\\bSET\\b", "\\bDELETE\\b",
34
- "\\bTRUNCATE\\b", "\\bALTER\\b", "\\bADD\\b", "\\bDROP\\b", "\\bCOLUMN\\b",
35
- "\\bCONSTRAINT\\b", "\\bPRIMARY\\b", "\\bKEY\\b", "\\bFOREIGN\\b", "\\bREFERENCES\\b",
36
- "\\bUNIQUE\\b", "\\bNOT\\b", "\\bNULL\\b", "\\bIS\\b", "\\bDISTINCT\\b",
37
- "\\bCASE\\b", "\\bWHEN\\b", "\\bTHEN\\b", "\\bELSE\\b", "\\bEND\\b",
38
- "\\bAS\\b", "\\bWITH\\b", "\\bBETWEEN\\b", "\\bLIKE\\b", "\\bIN\\b",
39
- "\\bEXISTS\\b", "\\bALL\\b", "\\bANY\\b", "\\bSOME\\b", "\\bDESC\\b", "\\bASC\\b"
40
- ]
41
- for pattern in keywords:
42
- regex = QRegularExpression(pattern, QRegularExpression.PatternOption.CaseInsensitiveOption)
43
- self.highlighting_rules.append((regex, keyword_format))
44
-
45
- # Functions
46
- function_format = QTextCharFormat()
47
- function_format.setForeground(QColor("#AA00AA")) # Purple
48
- functions = [
49
- "\\bAVG\\b", "\\bCOUNT\\b", "\\bSUM\\b", "\\bMAX\\b", "\\bMIN\\b",
50
- "\\bCOALESCE\\b", "\\bNVL\\b", "\\bNULLIF\\b", "\\bCAST\\b", "\\bCONVERT\\b",
51
- "\\bLOWER\\b", "\\bUPPER\\b", "\\bTRIM\\b", "\\bLTRIM\\b", "\\bRTRIM\\b",
52
- "\\bLENGTH\\b", "\\bSUBSTRING\\b", "\\bREPLACE\\b", "\\bCONCAT\\b",
53
- "\\bROUND\\b", "\\bFLOOR\\b", "\\bCEIL\\b", "\\bABS\\b", "\\bMOD\\b",
54
- "\\bCURRENT_DATE\\b", "\\bCURRENT_TIME\\b", "\\bCURRENT_TIMESTAMP\\b",
55
- "\\bEXTRACT\\b", "\\bDATE_PART\\b", "\\bTO_CHAR\\b", "\\bTO_DATE\\b"
56
- ]
57
- for pattern in functions:
58
- regex = QRegularExpression(pattern, QRegularExpression.PatternOption.CaseInsensitiveOption)
59
- self.highlighting_rules.append((regex, function_format))
60
-
61
- # Numbers
62
- number_format = QTextCharFormat()
63
- number_format.setForeground(QColor("#009900")) # Green
64
- self.highlighting_rules.append((
65
- QRegularExpression("\\b[0-9]+\\b"),
66
- number_format
67
- ))
68
-
69
- # Single-line string literals
70
- string_format = QTextCharFormat()
71
- string_format.setForeground(QColor("#CC6600")) # Orange/Brown
72
- self.highlighting_rules.append((
73
- QRegularExpression("'[^']*'"),
74
- string_format
75
- ))
76
- self.highlighting_rules.append((
77
- QRegularExpression("\"[^\"]*\""),
78
- string_format
79
- ))
80
-
81
- # Comments
82
- comment_format = QTextCharFormat()
83
- comment_format.setForeground(QColor("#777777")) # Gray
84
- comment_format.setFontItalic(True)
85
- self.highlighting_rules.append((
86
- QRegularExpression("--[^\n]*"),
87
- comment_format
88
- ))
89
-
90
- # Multi-line comments
91
- self.comment_start_expression = QRegularExpression("/\\*")
92
- self.comment_end_expression = QRegularExpression("\\*/")
93
- self.multi_line_comment_format = comment_format
94
-
95
- def highlightBlock(self, text):
96
- # Apply regular expression highlighting rules
97
- for pattern, format in self.highlighting_rules:
98
- match_iterator = pattern.globalMatch(text)
99
- while match_iterator.hasNext():
100
- match = match_iterator.next()
101
- self.setFormat(match.capturedStart(), match.capturedLength(), format)
102
-
103
- # Handle multi-line comments
104
- self.setCurrentBlockState(0)
105
-
106
- # If previous block was inside a comment, check if this block continues it
107
- start_index = 0
108
- if self.previousBlockState() != 1:
109
- # Find the start of a comment
110
- start_match = self.comment_start_expression.match(text)
111
- if start_match.hasMatch():
112
- start_index = start_match.capturedStart()
113
- else:
114
- return
115
-
116
- while start_index >= 0:
117
- # Find the end of the comment
118
- end_match = self.comment_end_expression.match(text, start_index)
119
-
120
- # If end match found
121
- if end_match.hasMatch():
122
- end_index = end_match.capturedStart()
123
- comment_length = end_index - start_index + end_match.capturedLength()
124
- self.setFormat(start_index, comment_length, self.multi_line_comment_format)
125
-
126
- # Look for next comment
127
- start_match = self.comment_start_expression.match(text, start_index + comment_length)
128
- if start_match.hasMatch():
129
- start_index = start_match.capturedStart()
130
- else:
131
- start_index = -1
132
- else:
133
- # No end found, comment continues to next block
134
- self.setCurrentBlockState(1) # Still inside comment
135
- comment_length = len(text) - start_index
136
- self.setFormat(start_index, comment_length, self.multi_line_comment_format)
137
- start_index = -1
138
-
139
- class LineNumberArea(QWidget):
140
- def __init__(self, editor):
141
- super().__init__(editor)
142
- self.editor = editor
143
23
 
144
- def sizeHint(self):
145
- return QSize(self.editor.line_number_area_width(), 0)
24
+ from sqlshell import create_test_data
25
+ from sqlshell.splash_screen import AnimatedSplashScreen
26
+ from sqlshell.syntax_highlighter import SQLSyntaxHighlighter
27
+ from sqlshell.editor import LineNumberArea, SQLEditor
28
+ from sqlshell.ui import FilterHeader, BarChartDelegate
29
+ from sqlshell.db import DatabaseManager
30
+ from sqlshell.query_tab import QueryTab
146
31
 
147
- def paintEvent(self, event):
148
- self.editor.line_number_area_paint_event(event)
149
-
150
- class SQLEditor(QPlainTextEdit):
32
+ class DraggableTablesList(QListWidget):
33
+ """Custom QListWidget that provides better drag functionality for table names."""
34
+
151
35
  def __init__(self, parent=None):
152
36
  super().__init__(parent)
153
- self.line_number_area = LineNumberArea(self)
154
-
155
- # Set monospaced font
156
- font = QFont("Consolas", 12) # Increased font size for better readability
157
- font.setFixedPitch(True)
158
- self.setFont(font)
159
-
160
- # Connect signals
161
- self.blockCountChanged.connect(self.update_line_number_area_width)
162
- self.updateRequest.connect(self.update_line_number_area)
163
-
164
- # Initialize
165
- self.update_line_number_area_width(0)
166
-
167
- # Set tab width to 4 spaces
168
- self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(' '))
169
-
170
- # Set placeholder text
171
- self.setPlaceholderText("Enter your SQL query here...")
172
-
173
- # Initialize completer
174
- self.completer = None
175
-
176
- # SQL Keywords for autocomplete
177
- self.sql_keywords = [
178
- "SELECT", "FROM", "WHERE", "AND", "OR", "INNER", "OUTER", "LEFT", "RIGHT", "JOIN",
179
- "ON", "GROUP", "BY", "HAVING", "ORDER", "LIMIT", "OFFSET", "UNION", "EXCEPT", "INTERSECT",
180
- "CREATE", "TABLE", "INDEX", "VIEW", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE",
181
- "TRUNCATE", "ALTER", "ADD", "DROP", "COLUMN", "CONSTRAINT", "PRIMARY", "KEY", "FOREIGN", "REFERENCES",
182
- "UNIQUE", "NOT", "NULL", "IS", "DISTINCT", "CASE", "WHEN", "THEN", "ELSE", "END",
183
- "AS", "WITH", "BETWEEN", "LIKE", "IN", "EXISTS", "ALL", "ANY", "SOME", "DESC", "ASC",
184
- "AVG", "COUNT", "SUM", "MAX", "MIN", "COALESCE", "CAST", "CONVERT"
185
- ]
186
-
187
- # Initialize with SQL keywords
188
- self.set_completer(QCompleter(self.sql_keywords))
189
-
190
- # Set modern selection color
191
- self.selection_color = QColor("#3498DB")
192
- self.selection_color.setAlpha(50) # Make it semi-transparent
193
-
194
- def set_completer(self, completer):
195
- """Set the completer for the editor"""
196
- if self.completer:
197
- self.completer.disconnect(self)
198
-
199
- self.completer = completer
200
-
201
- if not self.completer:
202
- return
203
-
204
- self.completer.setWidget(self)
205
- self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
206
- self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
207
- self.completer.activated.connect(self.insert_completion)
208
-
209
- def update_completer_model(self, words):
210
- """Update the completer model with new words"""
211
- if not self.completer:
212
- return
213
-
214
- # Combine SQL keywords with table/column names
215
- all_words = self.sql_keywords + words
216
-
217
- # Create a model with all words
218
- model = QStringListModel()
219
- model.setStringList(all_words)
220
-
221
- # Set the model to the completer
222
- self.completer.setModel(model)
223
-
224
- def text_under_cursor(self):
225
- """Get the text under the cursor for completion"""
226
- tc = self.textCursor()
227
- tc.select(QTextCursor.SelectionType.WordUnderCursor)
228
- return tc.selectedText()
229
-
230
- def insert_completion(self, completion):
231
- """Insert the completion text"""
232
- if self.completer.widget() != self:
233
- return
234
-
235
- tc = self.textCursor()
236
- extra = len(completion) - len(self.completer.completionPrefix())
237
- tc.movePosition(QTextCursor.MoveOperation.Left)
238
- tc.movePosition(QTextCursor.MoveOperation.EndOfWord)
239
- tc.insertText(completion[-extra:] + " ")
240
- self.setTextCursor(tc)
241
-
242
- def complete(self):
243
- """Show completion popup"""
244
- prefix = self.text_under_cursor()
37
+ self.parent = parent
38
+ self.setDragEnabled(True)
39
+ self.setDragDropMode(QListWidget.DragDropMode.DragOnly)
245
40
 
246
- if not prefix or len(prefix) < 2: # Only show completions for words with at least 2 characters
247
- if self.completer.popup().isVisible():
248
- self.completer.popup().hide()
249
- return
250
-
251
- self.completer.setCompletionPrefix(prefix)
252
-
253
- # If no completions, hide popup
254
- if self.completer.completionCount() == 0:
255
- self.completer.popup().hide()
256
- return
257
-
258
- # Get popup and position it under the current text
259
- popup = self.completer.popup()
260
- popup.setCurrentIndex(self.completer.completionModel().index(0, 0))
261
-
262
- # Calculate position for the popup
263
- cr = self.cursorRect()
264
- cr.setWidth(self.completer.popup().sizeHintForColumn(0) +
265
- self.completer.popup().verticalScrollBar().sizeHint().width())
266
-
267
- # Show the popup
268
- self.completer.complete(cr)
269
-
270
- def keyPressEvent(self, event):
271
- # Handle completer popup navigation
272
- if self.completer and self.completer.popup().isVisible():
273
- # Handle navigation keys for the popup
274
- if event.key() in [Qt.Key.Key_Enter, Qt.Key.Key_Return, Qt.Key.Key_Tab,
275
- Qt.Key.Key_Escape, Qt.Key.Key_Up, Qt.Key.Key_Down]:
276
- event.ignore()
277
- return
41
+ # Apply custom styling
42
+ self.setStyleSheet("""
43
+ QListWidget {
44
+ background-color: rgba(255, 255, 255, 0.1);
45
+ border: none;
46
+ border-radius: 4px;
47
+ color: white;
48
+ }
49
+ QListWidget::item:selected {
50
+ background-color: rgba(255, 255, 255, 0.2);
51
+ }
52
+ QListWidget::item:hover:!selected {
53
+ background-color: rgba(255, 255, 255, 0.1);
54
+ }
55
+ """)
278
56
 
279
- # Handle special key combinations
280
- if event.key() == Qt.Key.Key_Tab:
281
- # Insert 4 spaces instead of a tab character
282
- self.insertPlainText(" ")
283
- return
284
-
285
- # Auto-indentation for new lines
286
- if event.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
287
- cursor = self.textCursor()
288
- block = cursor.block()
289
- text = block.text()
290
-
291
- # Get the indentation of the current line
292
- indentation = ""
293
- for char in text:
294
- if char.isspace():
295
- indentation += char
296
- else:
297
- break
298
-
299
- # Check if line ends with an opening bracket or keywords that should increase indentation
300
- increase_indent = ""
301
- if text.strip().endswith("(") or any(text.strip().upper().endswith(keyword) for keyword in
302
- ["SELECT", "FROM", "WHERE", "GROUP BY", "ORDER BY", "HAVING"]):
303
- increase_indent = " "
304
-
305
- # Insert new line with proper indentation
306
- super().keyPressEvent(event)
307
- self.insertPlainText(indentation + increase_indent)
57
+ def startDrag(self, supportedActions):
58
+ """Override startDrag to customize the drag data."""
59
+ item = self.currentItem()
60
+ if not item:
308
61
  return
309
62
 
310
- # Handle keyboard shortcuts
311
- if event.modifiers() == Qt.KeyboardModifier.ControlModifier:
312
- if event.key() == Qt.Key.Key_Space:
313
- # Show completion popup
314
- self.complete()
315
- return
316
- elif event.key() == Qt.Key.Key_K:
317
- # Comment/uncomment the selected lines
318
- self.toggle_comment()
319
- return
320
-
321
- # For normal key presses
322
- super().keyPressEvent(event)
323
-
324
- # Check for autocomplete after typing
325
- if event.text() and not event.text().isspace():
326
- self.complete()
327
-
328
- def paintEvent(self, event):
329
- # Call the parent's paintEvent first
330
- super().paintEvent(event)
331
-
332
- # Get the current cursor
333
- cursor = self.textCursor()
334
-
335
- # If there's a selection, paint custom highlight
336
- if cursor.hasSelection():
337
- # Create a painter for this widget
338
- painter = QPainter(self.viewport())
339
-
340
- # Get the selection start and end positions
341
- start = cursor.selectionStart()
342
- end = cursor.selectionEnd()
343
-
344
- # Create temporary cursor to get the rectangles
345
- temp_cursor = QTextCursor(cursor)
346
-
347
- # Move to start and get the starting position
348
- temp_cursor.setPosition(start)
349
- start_pos = self.cursorRect(temp_cursor)
350
-
351
- # Move to end and get the ending position
352
- temp_cursor.setPosition(end)
353
- end_pos = self.cursorRect(temp_cursor)
354
-
355
- # Set the highlight color with transparency
356
- painter.setBrush(QBrush(self.selection_color))
357
- painter.setPen(Qt.PenStyle.NoPen)
358
-
359
- # Draw the highlight rectangle
360
- if start_pos.top() == end_pos.top():
361
- # Single line selection
362
- painter.drawRect(QRect(start_pos.left(), start_pos.top(),
363
- end_pos.right() - start_pos.left(), start_pos.height()))
364
- else:
365
- # Multi-line selection
366
- # First line
367
- painter.drawRect(QRect(start_pos.left(), start_pos.top(),
368
- self.viewport().width() - start_pos.left(), start_pos.height()))
369
-
370
- # Middle lines (if any)
371
- if end_pos.top() > start_pos.top() + start_pos.height():
372
- painter.drawRect(QRect(0, start_pos.top() + start_pos.height(),
373
- self.viewport().width(),
374
- end_pos.top() - (start_pos.top() + start_pos.height())))
375
-
376
- # Last line
377
- painter.drawRect(QRect(0, end_pos.top(), end_pos.right(), end_pos.height()))
378
-
379
- painter.end()
380
-
381
- def focusInEvent(self, event):
382
- super().focusInEvent(event)
383
- # Show temporary hint in status bar when editor gets focus
384
- if hasattr(self.parent(), 'statusBar'):
385
- self.parent().parent().parent().statusBar().showMessage('Press Ctrl+Space for autocomplete', 2000)
386
-
387
- def toggle_comment(self):
388
- cursor = self.textCursor()
389
- if cursor.hasSelection():
390
- # Get the selected text
391
- start = cursor.selectionStart()
392
- end = cursor.selectionEnd()
393
-
394
- # Remember the selection
395
- cursor.setPosition(start)
396
- start_block = cursor.blockNumber()
397
- cursor.setPosition(end)
398
- end_block = cursor.blockNumber()
399
-
400
- # Process each line in the selection
401
- cursor.setPosition(start)
402
- cursor.beginEditBlock()
403
-
404
- for _ in range(start_block, end_block + 1):
405
- # Move to start of line
406
- cursor.movePosition(cursor.MoveOperation.StartOfLine)
407
-
408
- # Check if the line is already commented
409
- line_text = cursor.block().text().lstrip()
410
- if line_text.startswith('--'):
411
- # Remove comment
412
- pos = cursor.block().text().find('--')
413
- cursor.setPosition(cursor.block().position() + pos)
414
- cursor.deleteChar()
415
- cursor.deleteChar()
416
- else:
417
- # Add comment
418
- cursor.insertText('--')
419
-
420
- # Move to next line if not at the end
421
- if not cursor.atEnd():
422
- cursor.movePosition(cursor.MoveOperation.NextBlock)
423
-
424
- cursor.endEditBlock()
425
- else:
426
- # Comment/uncomment current line
427
- cursor.movePosition(cursor.MoveOperation.StartOfLine)
428
- cursor.movePosition(cursor.MoveOperation.EndOfLine, cursor.MoveMode.KeepAnchor)
429
- line_text = cursor.selectedText().lstrip()
430
-
431
- cursor.movePosition(cursor.MoveOperation.StartOfLine)
432
- if line_text.startswith('--'):
433
- # Remove comment
434
- pos = cursor.block().text().find('--')
435
- cursor.setPosition(cursor.block().position() + pos)
436
- cursor.deleteChar()
437
- cursor.deleteChar()
438
- else:
439
- # Add comment
440
- cursor.insertText('--')
441
-
442
- def line_number_area_width(self):
443
- digits = 1
444
- max_num = max(1, self.blockCount())
445
- while max_num >= 10:
446
- max_num //= 10
447
- digits += 1
448
-
449
- space = 3 + self.fontMetrics().horizontalAdvance('9') * digits
450
- return space
451
-
452
- def update_line_number_area_width(self, _):
453
- self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
454
-
455
- def update_line_number_area(self, rect, dy):
456
- if dy:
457
- self.line_number_area.scroll(0, dy)
458
- else:
459
- self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
460
-
461
- if rect.contains(self.viewport().rect()):
462
- self.update_line_number_area_width(0)
463
-
464
- def resizeEvent(self, event):
465
- super().resizeEvent(event)
466
- cr = self.contentsRect()
467
- self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()))
468
-
469
- def line_number_area_paint_event(self, event):
470
- painter = QPainter(self.line_number_area)
471
- painter.fillRect(event.rect(), QColor("#f0f0f0")) # Light gray background
472
-
473
- block = self.firstVisibleBlock()
474
- block_number = block.blockNumber()
475
- top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
476
- bottom = top + round(self.blockBoundingRect(block).height())
477
-
478
- while block.isValid() and top <= event.rect().bottom():
479
- if block.isVisible() and bottom >= event.rect().top():
480
- number = str(block_number + 1)
481
- painter.setPen(QColor("#808080")) # Gray text
482
- painter.drawText(0, top, self.line_number_area.width() - 5,
483
- self.fontMetrics().height(),
484
- Qt.AlignmentFlag.AlignRight, number)
485
-
486
- block = block.next()
487
- top = bottom
488
- bottom = top + round(self.blockBoundingRect(block).height())
489
- block_number += 1
63
+ # Extract the table name without the file info in parentheses
64
+ table_name = item.text().split(' (')[0]
65
+
66
+ # Create mime data with the table name
67
+ mime_data = QMimeData()
68
+ mime_data.setText(table_name)
69
+
70
+ # Create drag object
71
+ drag = QDrag(self)
72
+ drag.setMimeData(mime_data)
73
+
74
+ # Create a visually appealing drag pixmap
75
+ font = self.font()
76
+ font.setBold(True)
77
+ metrics = self.fontMetrics()
78
+ text_width = metrics.horizontalAdvance(table_name)
79
+ text_height = metrics.height()
80
+
81
+ # Make the pixmap large enough for the text plus padding and a small icon
82
+ padding = 10
83
+ pixmap = QPixmap(text_width + padding * 2 + 16, text_height + padding)
84
+ pixmap.fill(Qt.GlobalColor.transparent)
85
+
86
+ # Begin painting
87
+ painter = QPainter(pixmap)
88
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
89
+
90
+ # Draw a nice rounded rectangle background
91
+ bg_color = QColor(44, 62, 80, 220) # Dark blue with transparency
92
+ painter.setBrush(QBrush(bg_color))
93
+ painter.setPen(Qt.PenStyle.NoPen)
94
+ painter.drawRoundedRect(0, 0, pixmap.width(), pixmap.height(), 5, 5)
95
+
96
+ # Draw text
97
+ painter.setPen(Qt.GlobalColor.white)
98
+ painter.setFont(font)
99
+ painter.drawText(int(padding + 16), int(text_height + (padding / 2) - 2), table_name)
100
+
101
+ # Draw a small database icon (simulated)
102
+ icon_x = padding / 2
103
+ icon_y = (pixmap.height() - 12) / 2
104
+
105
+ # Draw a simple database icon as a blue circle with lines
106
+ table_icon_color = QColor("#3498DB")
107
+ painter.setBrush(QBrush(table_icon_color))
108
+ painter.setPen(Qt.GlobalColor.white)
109
+ painter.drawEllipse(int(icon_x), int(icon_y), 12, 12)
110
+
111
+ # Draw "table" lines inside the circle
112
+ painter.setPen(Qt.GlobalColor.white)
113
+ painter.drawLine(int(icon_x + 3), int(icon_y + 4), int(icon_x + 9), int(icon_y + 4))
114
+ painter.drawLine(int(icon_x + 3), int(icon_y + 6), int(icon_x + 9), int(icon_y + 6))
115
+ painter.drawLine(int(icon_x + 3), int(icon_y + 8), int(icon_x + 9), int(icon_y + 8))
116
+
117
+ painter.end()
118
+
119
+ # Set the drag pixmap
120
+ drag.setPixmap(pixmap)
121
+
122
+ # Set hotspot to be at the top-left corner of the text
123
+ drag.setHotSpot(QPoint(padding, pixmap.height() // 2))
124
+
125
+ # Execute drag operation
126
+ result = drag.exec(supportedActions)
127
+
128
+ # Optional: add a highlight effect after dragging
129
+ if result == Qt.DropAction.CopyAction and item:
130
+ # Briefly highlight the dragged item
131
+ orig_bg = item.background()
132
+ item.setBackground(QBrush(QColor(26, 188, 156, 100))) # Light green highlight
133
+
134
+ # Reset after a short delay
135
+ QTimer.singleShot(300, lambda: item.setBackground(orig_bg))
490
136
 
491
137
  class SQLShell(QMainWindow):
492
138
  def __init__(self):
493
139
  super().__init__()
494
- self.current_db_type = 'duckdb' # Default to DuckDB
495
- self.conn = duckdb.connect(':memory:') # Create in-memory DuckDB connection by default
496
- self.loaded_tables = {} # Keep track of loaded tables
497
- self.table_columns = {} # Keep track of table columns
140
+ self.db_manager = DatabaseManager()
141
+ self.current_df = None # Store the current DataFrame for filtering
142
+ self.filter_widgets = [] # Store filter line edits
143
+ self.current_project_file = None # Store the current project file path
144
+ self.recent_projects = [] # Store list of recent projects
145
+ self.max_recent_projects = 10 # Maximum number of recent projects to track
146
+ self.tabs = [] # Store list of all tabs
147
+
148
+ # Load recent projects from settings
149
+ self.load_recent_projects()
498
150
 
499
151
  # Define color scheme
500
152
  self.colors = {
@@ -514,6 +166,9 @@ class SQLShell(QMainWindow):
514
166
 
515
167
  self.init_ui()
516
168
  self.apply_stylesheet()
169
+
170
+ # Create initial tab
171
+ self.add_tab()
517
172
 
518
173
  def apply_stylesheet(self):
519
174
  """Apply custom stylesheet to the application"""
@@ -658,6 +313,44 @@ class SQLShell(QMainWindow):
658
313
  padding: 8px;
659
314
  }}
660
315
 
316
+ QTabWidget::pane {{
317
+ border: 1px solid {self.colors['border']};
318
+ border-radius: 4px;
319
+ top: -1px;
320
+ background-color: white;
321
+ }}
322
+
323
+ QTabBar::tab {{
324
+ background-color: {self.colors['light_bg']};
325
+ color: {self.colors['text']};
326
+ border: 1px solid {self.colors['border']};
327
+ border-bottom: none;
328
+ border-top-left-radius: 4px;
329
+ border-top-right-radius: 4px;
330
+ padding: 8px 12px;
331
+ margin-right: 2px;
332
+ min-width: 80px;
333
+ }}
334
+
335
+ QTabBar::tab:selected {{
336
+ background-color: white;
337
+ border-bottom: 1px solid white;
338
+ }}
339
+
340
+ QTabBar::tab:hover:!selected {{
341
+ background-color: #E3F2FD;
342
+ }}
343
+
344
+ QTabBar::close-button {{
345
+ image: url(close.png);
346
+ subcontrol-position: right;
347
+ }}
348
+
349
+ QTabBar::close-button:hover {{
350
+ background-color: rgba(255, 0, 0, 0.2);
351
+ border-radius: 2px;
352
+ }}
353
+
661
354
  QPlainTextEdit, QTextEdit {{
662
355
  background-color: white;
663
356
  border-radius: 4px;
@@ -672,7 +365,112 @@ class SQLShell(QMainWindow):
672
365
 
673
366
  def init_ui(self):
674
367
  self.setWindowTitle('SQL Shell')
675
- self.setGeometry(100, 100, 1400, 800)
368
+
369
+ # Get screen geometry for smart sizing
370
+ screen = QApplication.primaryScreen()
371
+ screen_geometry = screen.availableGeometry()
372
+ screen_width = screen_geometry.width()
373
+ screen_height = screen_geometry.height()
374
+
375
+ # Calculate adaptive window size based on screen size
376
+ # Use 85% of screen size for larger screens, fixed size for smaller screens
377
+ if screen_width >= 1920 and screen_height >= 1080: # Larger screens
378
+ window_width = int(screen_width * 0.85)
379
+ window_height = int(screen_height * 0.85)
380
+ self.setGeometry(
381
+ (screen_width - window_width) // 2, # Center horizontally
382
+ (screen_height - window_height) // 2, # Center vertically
383
+ window_width,
384
+ window_height
385
+ )
386
+ else: # Default for smaller screens
387
+ self.setGeometry(100, 100, 1400, 800)
388
+
389
+ # Remember if the window was maximized
390
+ self.was_maximized = False
391
+
392
+ # Set application icon
393
+ icon_path = os.path.join(os.path.dirname(__file__), "resources", "icon.png")
394
+ if os.path.exists(icon_path):
395
+ self.setWindowIcon(QIcon(icon_path))
396
+ else:
397
+ # Fallback to the main logo if the icon isn't found
398
+ main_logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sqlshell_logo.png")
399
+ if os.path.exists(main_logo_path):
400
+ self.setWindowIcon(QIcon(main_logo_path))
401
+
402
+ # Create menu bar
403
+ menubar = self.menuBar()
404
+ file_menu = menubar.addMenu('&File')
405
+
406
+ # Project management actions
407
+ new_project_action = file_menu.addAction('New Project')
408
+ new_project_action.setShortcut('Ctrl+N')
409
+ new_project_action.triggered.connect(self.new_project)
410
+
411
+ open_project_action = file_menu.addAction('Open Project...')
412
+ open_project_action.setShortcut('Ctrl+O')
413
+ open_project_action.triggered.connect(self.open_project)
414
+
415
+ # Add Recent Projects submenu
416
+ self.recent_projects_menu = file_menu.addMenu('Recent Projects')
417
+ self.update_recent_projects_menu()
418
+
419
+ save_project_action = file_menu.addAction('Save Project')
420
+ save_project_action.setShortcut('Ctrl+S')
421
+ save_project_action.triggered.connect(self.save_project)
422
+
423
+ save_project_as_action = file_menu.addAction('Save Project As...')
424
+ save_project_as_action.setShortcut('Ctrl+Shift+S')
425
+ save_project_as_action.triggered.connect(self.save_project_as)
426
+
427
+ file_menu.addSeparator()
428
+
429
+ exit_action = file_menu.addAction('Exit')
430
+ exit_action.setShortcut('Ctrl+Q')
431
+ exit_action.triggered.connect(self.close)
432
+
433
+ # Add View menu for window management
434
+ view_menu = menubar.addMenu('&View')
435
+
436
+ # Maximized window option
437
+ maximize_action = view_menu.addAction('Maximize Window')
438
+ maximize_action.setShortcut('F11')
439
+ maximize_action.triggered.connect(self.toggle_maximize_window)
440
+
441
+ # Zoom submenu
442
+ zoom_menu = view_menu.addMenu('Zoom')
443
+
444
+ zoom_in_action = zoom_menu.addAction('Zoom In')
445
+ zoom_in_action.setShortcut('Ctrl++')
446
+ zoom_in_action.triggered.connect(lambda: self.change_zoom(1.1))
447
+
448
+ zoom_out_action = zoom_menu.addAction('Zoom Out')
449
+ zoom_out_action.setShortcut('Ctrl+-')
450
+ zoom_out_action.triggered.connect(lambda: self.change_zoom(0.9))
451
+
452
+ reset_zoom_action = zoom_menu.addAction('Reset Zoom')
453
+ reset_zoom_action.setShortcut('Ctrl+0')
454
+ reset_zoom_action.triggered.connect(lambda: self.reset_zoom())
455
+
456
+ # Add Tab menu
457
+ tab_menu = menubar.addMenu('&Tab')
458
+
459
+ new_tab_action = tab_menu.addAction('New Tab')
460
+ new_tab_action.setShortcut('Ctrl+T')
461
+ new_tab_action.triggered.connect(self.add_tab)
462
+
463
+ duplicate_tab_action = tab_menu.addAction('Duplicate Current Tab')
464
+ duplicate_tab_action.setShortcut('Ctrl+D')
465
+ duplicate_tab_action.triggered.connect(self.duplicate_current_tab)
466
+
467
+ rename_tab_action = tab_menu.addAction('Rename Current Tab')
468
+ rename_tab_action.setShortcut('Ctrl+R')
469
+ rename_tab_action.triggered.connect(self.rename_current_tab)
470
+
471
+ close_tab_action = tab_menu.addAction('Close Current Tab')
472
+ close_tab_action.setShortcut('Ctrl+W')
473
+ close_tab_action.triggered.connect(self.close_current_tab)
676
474
 
677
475
  # Create custom status bar
678
476
  status_bar = QStatusBar()
@@ -743,28 +541,16 @@ class SQLShell(QMainWindow):
743
541
  left_layout.addLayout(table_actions_layout)
744
542
 
745
543
  # Tables list with custom styling
746
- self.tables_list = QListWidget()
747
- self.tables_list.setStyleSheet("""
748
- QListWidget {
749
- background-color: rgba(255, 255, 255, 0.1);
750
- border: none;
751
- border-radius: 4px;
752
- color: white;
753
- }
754
- QListWidget::item:selected {
755
- background-color: rgba(255, 255, 255, 0.2);
756
- }
757
- QListWidget::item:hover:!selected {
758
- background-color: rgba(255, 255, 255, 0.1);
759
- }
760
- """)
544
+ self.tables_list = DraggableTablesList()
761
545
  self.tables_list.itemClicked.connect(self.show_table_preview)
546
+ self.tables_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
547
+ self.tables_list.customContextMenuRequested.connect(self.show_tables_context_menu)
762
548
  left_layout.addWidget(self.tables_list)
763
549
 
764
550
  # Add spacer at the bottom
765
551
  left_layout.addStretch()
766
552
 
767
- # Right panel for query and results
553
+ # Right panel for query tabs and results
768
554
  right_panel = QFrame()
769
555
  right_panel.setObjectName("content_panel")
770
556
  right_layout = QVBoxLayout(right_panel)
@@ -776,198 +562,203 @@ class SQLShell(QMainWindow):
776
562
  query_header.setObjectName("header_label")
777
563
  right_layout.addWidget(query_header)
778
564
 
779
- # Create splitter for query and results
780
- splitter = QSplitter(Qt.Orientation.Vertical)
781
- splitter.setHandleWidth(8)
782
- splitter.setChildrenCollapsible(False)
783
-
784
- # Top part - Query section
785
- query_widget = QFrame()
786
- query_widget.setObjectName("content_panel")
787
- query_layout = QVBoxLayout(query_widget)
788
- query_layout.setContentsMargins(16, 16, 16, 16)
789
- query_layout.setSpacing(12)
790
-
791
- # Query input
792
- self.query_edit = SQLEditor()
793
- # Apply syntax highlighting to the query editor
794
- self.sql_highlighter = SQLSyntaxHighlighter(self.query_edit.document())
795
- query_layout.addWidget(self.query_edit)
796
-
797
- # Button row
798
- button_layout = QHBoxLayout()
799
- button_layout.setSpacing(8)
800
-
801
- self.execute_btn = QPushButton('Execute Query')
802
- self.execute_btn.setObjectName("primary_button")
803
- self.execute_btn.setIcon(QIcon.fromTheme("media-playback-start"))
804
- self.execute_btn.clicked.connect(self.execute_query)
805
- self.execute_btn.setToolTip("Execute Query (Ctrl+Enter)")
806
-
807
- self.clear_btn = QPushButton('Clear')
808
- self.clear_btn.setIcon(QIcon.fromTheme("edit-clear"))
809
- self.clear_btn.clicked.connect(self.clear_query)
810
-
811
- button_layout.addWidget(self.execute_btn)
812
- button_layout.addWidget(self.clear_btn)
813
- button_layout.addStretch()
814
-
815
- query_layout.addLayout(button_layout)
816
-
817
- # Bottom part - Results section
818
- results_widget = QFrame()
819
- results_widget.setObjectName("content_panel")
820
- results_layout = QVBoxLayout(results_widget)
821
- results_layout.setContentsMargins(16, 16, 16, 16)
822
- results_layout.setSpacing(12)
823
-
824
- # Results header with row count and export options
825
- results_header_layout = QHBoxLayout()
826
-
827
- results_title = QLabel("RESULTS")
828
- results_title.setObjectName("header_label")
829
-
830
- self.row_count_label = QLabel("")
831
- self.row_count_label.setStyleSheet(f"color: {self.colors['text_light']}; font-style: italic;")
832
-
833
- results_header_layout.addWidget(results_title)
834
- results_header_layout.addWidget(self.row_count_label)
835
- results_header_layout.addStretch()
836
-
837
- # Export buttons
838
- export_layout = QHBoxLayout()
839
- export_layout.setSpacing(8)
840
-
841
- self.export_excel_btn = QPushButton('Export to Excel')
842
- self.export_excel_btn.setIcon(QIcon.fromTheme("x-office-spreadsheet"))
843
- self.export_excel_btn.clicked.connect(self.export_to_excel)
844
-
845
- self.export_parquet_btn = QPushButton('Export to Parquet')
846
- self.export_parquet_btn.setIcon(QIcon.fromTheme("document-save"))
847
- self.export_parquet_btn.clicked.connect(self.export_to_parquet)
848
-
849
- export_layout.addWidget(self.export_excel_btn)
850
- export_layout.addWidget(self.export_parquet_btn)
851
-
852
- results_header_layout.addLayout(export_layout)
853
- results_layout.addLayout(results_header_layout)
854
-
855
- # Table widget for results with modern styling
856
- self.results_table = QTableWidget()
857
- self.results_table.setSortingEnabled(True)
858
- self.results_table.setAlternatingRowColors(True)
859
- self.results_table.horizontalHeader().setStretchLastSection(True)
860
- self.results_table.horizontalHeader().setSectionsMovable(True)
861
- self.results_table.verticalHeader().setVisible(False)
862
- self.results_table.setShowGrid(True)
863
- self.results_table.setGridStyle(Qt.PenStyle.SolidLine)
864
- self.results_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
865
-
866
- results_layout.addWidget(self.results_table)
867
-
868
- # Add widgets to splitter
869
- splitter.addWidget(query_widget)
870
- splitter.addWidget(results_widget)
565
+ # Create tab widget for multiple queries
566
+ self.tab_widget = QTabWidget()
567
+ self.tab_widget.setTabsClosable(True)
568
+ self.tab_widget.setMovable(True)
569
+ self.tab_widget.tabCloseRequested.connect(self.close_tab)
570
+
571
+ # Connect double-click signal for direct tab renaming
572
+ self.tab_widget.tabBarDoubleClicked.connect(self.handle_tab_double_click)
871
573
 
872
- # Set initial sizes for splitter
873
- splitter.setSizes([300, 500])
574
+ # Add a "+" button to the tab bar
575
+ self.tab_widget.setCornerWidget(self.create_tab_corner_widget())
874
576
 
875
- right_layout.addWidget(splitter)
577
+ right_layout.addWidget(self.tab_widget)
876
578
 
877
579
  # Add panels to main layout
878
580
  main_layout.addWidget(left_panel, 1)
879
581
  main_layout.addWidget(right_panel, 4)
880
582
 
881
583
  # Status bar
882
- self.statusBar().showMessage('Ready | Ctrl+Enter: Execute Query | Ctrl+K: Toggle Comment')
883
-
884
- # Show keyboard shortcuts in a tooltip for the query editor
885
- self.query_edit.setToolTip(
886
- "Keyboard Shortcuts:\n"
887
- "Ctrl+Enter: Execute Query\n"
888
- "Ctrl+K: Toggle Comment\n"
889
- "Tab: Insert 4 spaces\n"
890
- "Ctrl+Space: Show autocomplete"
891
- )
584
+ self.statusBar().showMessage('Ready | Ctrl+Enter: Execute Query | Ctrl+K: Toggle Comment | Ctrl+T: New Tab')
585
+
586
+ def create_tab_corner_widget(self):
587
+ """Create a corner widget with a + button to add new tabs"""
588
+ corner_widget = QWidget()
589
+ layout = QHBoxLayout(corner_widget)
590
+ layout.setContentsMargins(0, 0, 0, 0)
591
+ layout.setSpacing(0)
592
+
593
+ add_tab_btn = QToolButton()
594
+ add_tab_btn.setText("+")
595
+ add_tab_btn.setToolTip("Add new tab (Ctrl+T)")
596
+ add_tab_btn.setStyleSheet("""
597
+ QToolButton {
598
+ background-color: transparent;
599
+ border: none;
600
+ border-radius: 4px;
601
+ padding: 4px;
602
+ font-weight: bold;
603
+ font-size: 16px;
604
+ color: #3498DB;
605
+ }
606
+ QToolButton:hover {
607
+ background-color: rgba(52, 152, 219, 0.2);
608
+ }
609
+ QToolButton:pressed {
610
+ background-color: rgba(52, 152, 219, 0.4);
611
+ }
612
+ """)
613
+ add_tab_btn.clicked.connect(self.add_tab)
614
+
615
+ layout.addWidget(add_tab_btn)
616
+ return corner_widget
617
+
618
+ def populate_table(self, df):
619
+ """Populate the results table with DataFrame data using memory-efficient chunking"""
620
+ try:
621
+ # Get the current tab
622
+ current_tab = self.get_current_tab()
623
+ if not current_tab:
624
+ return
625
+
626
+ # Store the current DataFrame for filtering
627
+ current_tab.current_df = df.copy()
628
+ self.current_df = df.copy() # Keep this for compatibility with existing code
629
+
630
+ # Remember which columns had bar charts
631
+ header = current_tab.results_table.horizontalHeader()
632
+ if isinstance(header, FilterHeader):
633
+ columns_with_bars = header.columns_with_bars.copy()
634
+ else:
635
+ columns_with_bars = set()
636
+
637
+ # Clear existing data
638
+ current_tab.results_table.clearContents()
639
+ current_tab.results_table.setRowCount(0)
640
+ current_tab.results_table.setColumnCount(0)
641
+
642
+ if df.empty:
643
+ self.statusBar().showMessage("Query returned no results")
644
+ return
645
+
646
+ # Set up the table dimensions
647
+ row_count = len(df)
648
+ col_count = len(df.columns)
649
+ current_tab.results_table.setColumnCount(col_count)
650
+
651
+ # Set column headers
652
+ headers = [str(col) for col in df.columns]
653
+ current_tab.results_table.setHorizontalHeaderLabels(headers)
654
+
655
+ # Calculate chunk size (adjust based on available memory)
656
+ CHUNK_SIZE = 1000
657
+
658
+ # Process data in chunks to avoid memory issues with large datasets
659
+ for chunk_start in range(0, row_count, CHUNK_SIZE):
660
+ chunk_end = min(chunk_start + CHUNK_SIZE, row_count)
661
+ chunk = df.iloc[chunk_start:chunk_end]
662
+
663
+ # Add rows for this chunk
664
+ current_tab.results_table.setRowCount(chunk_end)
665
+
666
+ for row_idx, (_, row_data) in enumerate(chunk.iterrows(), start=chunk_start):
667
+ for col_idx, value in enumerate(row_data):
668
+ formatted_value = self.format_value(value)
669
+ item = QTableWidgetItem(formatted_value)
670
+ current_tab.results_table.setItem(row_idx, col_idx, item)
671
+
672
+ # Process events to keep UI responsive
673
+ QApplication.processEvents()
674
+
675
+ # Optimize column widths
676
+ current_tab.results_table.resizeColumnsToContents()
677
+
678
+ # Restore bar charts for columns that previously had them
679
+ header = current_tab.results_table.horizontalHeader()
680
+ if isinstance(header, FilterHeader):
681
+ for col_idx in columns_with_bars:
682
+ if col_idx < col_count: # Only if column still exists
683
+ header.toggle_bar_chart(col_idx)
684
+
685
+ # Update row count label
686
+ current_tab.row_count_label.setText(f"{row_count:,} rows")
687
+
688
+ # Update status
689
+ memory_usage = df.memory_usage(deep=True).sum() / (1024 * 1024) # Convert to MB
690
+ self.statusBar().showMessage(
691
+ f"Loaded {row_count:,} rows, {col_count} columns. Memory usage: {memory_usage:.1f} MB"
692
+ )
693
+
694
+ except Exception as e:
695
+ QMessageBox.critical(self, "Error",
696
+ f"Failed to populate results table:\n\n{str(e)}")
697
+ self.statusBar().showMessage("Failed to display results")
698
+
699
+ def apply_filters(self):
700
+ """Apply filters to the table based on filter inputs"""
701
+ if self.current_df is None or not self.filter_widgets:
702
+ return
703
+
704
+ try:
705
+ # Start with the original DataFrame
706
+ filtered_df = self.current_df.copy()
707
+
708
+ # Apply each non-empty filter
709
+ for col_idx, filter_widget in enumerate(self.filter_widgets):
710
+ filter_text = filter_widget.text().strip()
711
+ if filter_text:
712
+ col_name = self.current_df.columns[col_idx]
713
+ # Convert column to string for filtering
714
+ filtered_df[col_name] = filtered_df[col_name].astype(str)
715
+ filtered_df = filtered_df[filtered_df[col_name].str.contains(filter_text, case=False, na=False)]
716
+
717
+ # Update table with filtered data
718
+ row_count = len(filtered_df)
719
+ for row_idx in range(row_count):
720
+ for col_idx, value in enumerate(filtered_df.iloc[row_idx]):
721
+ formatted_value = self.format_value(value)
722
+ item = QTableWidgetItem(formatted_value)
723
+ self.results_table.setItem(row_idx, col_idx, item)
724
+
725
+ # Hide rows that don't match filter
726
+ for row_idx in range(row_count + 1, self.results_table.rowCount()):
727
+ self.results_table.hideRow(row_idx)
728
+
729
+ # Show all filtered rows
730
+ for row_idx in range(1, row_count + 1):
731
+ self.results_table.showRow(row_idx)
732
+
733
+ # Update status
734
+ self.statusBar().showMessage(f"Showing {row_count:,} rows after filtering")
735
+
736
+ except Exception as e:
737
+ self.statusBar().showMessage(f"Error applying filters: {str(e)}")
892
738
 
893
739
  def format_value(self, value):
894
- """Format values for display"""
740
+ """Format cell values efficiently"""
895
741
  if pd.isna(value):
896
- return 'NULL'
897
- elif isinstance(value, (int, np.integer)):
898
- return f"{value:,}"
742
+ return "NULL"
899
743
  elif isinstance(value, (float, np.floating)):
900
- return f"{value:,.2f}"
901
- elif isinstance(value, (datetime, pd.Timestamp)):
902
- return value.strftime('%Y-%m-%d %H:%M:%S')
744
+ if value.is_integer():
745
+ return str(int(value))
746
+ return f"{value:.6g}" # Use general format with up to 6 significant digits
747
+ elif isinstance(value, (pd.Timestamp, datetime)):
748
+ return value.strftime("%Y-%m-%d %H:%M:%S")
749
+ elif isinstance(value, (np.integer, int)):
750
+ return str(value)
751
+ elif isinstance(value, bool):
752
+ return str(value)
753
+ elif isinstance(value, (bytes, bytearray)):
754
+ return value.hex()
903
755
  return str(value)
904
756
 
905
- def populate_table(self, df):
906
- """Populate the table widget with DataFrame content"""
907
- if len(df) == 0:
908
- self.results_table.setRowCount(0)
909
- self.results_table.setColumnCount(0)
910
- self.row_count_label.setText("No results")
911
- return
912
-
913
- # Set dimensions
914
- self.results_table.setRowCount(len(df))
915
- self.results_table.setColumnCount(len(df.columns))
916
-
917
- # Set headers
918
- self.results_table.setHorizontalHeaderLabels(df.columns)
919
-
920
- # Populate data
921
- for i, (_, row) in enumerate(df.iterrows()):
922
- for j, value in enumerate(row):
923
- formatted_value = self.format_value(value)
924
- item = QTableWidgetItem(formatted_value)
925
-
926
- # Set alignment based on data type
927
- if isinstance(value, (int, float, np.integer, np.floating)):
928
- item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
929
- else:
930
- item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
931
-
932
- # Make cells read-only
933
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
934
-
935
- # Apply special styling for NULL values
936
- if pd.isna(value):
937
- item.setForeground(QColor(self.colors['text_light']))
938
- item.setBackground(QColor("#F8F9FA"))
939
-
940
- self.results_table.setItem(i, j, item)
941
-
942
- # Auto-adjust column widths while ensuring minimum and maximum sizes
943
- self.results_table.resizeColumnsToContents()
944
- for i in range(len(df.columns)):
945
- width = self.results_table.columnWidth(i)
946
- self.results_table.setColumnWidth(i, min(max(width, 80), 300))
947
-
948
- # Update row count
949
- row_text = "row" if len(df) == 1 else "rows"
950
- self.row_count_label.setText(f"{len(df):,} {row_text}")
951
-
952
- # Apply zebra striping
953
- for i in range(len(df)):
954
- if i % 2 == 0:
955
- for j in range(len(df.columns)):
956
- item = self.results_table.item(i, j)
957
- if item and not pd.isna(df.iloc[i, j]):
958
- item.setBackground(QColor("#FFFFFF"))
959
- else:
960
- for j in range(len(df.columns)):
961
- item = self.results_table.item(i, j)
962
- if item and not pd.isna(df.iloc[i, j]):
963
- item.setBackground(QColor("#F8F9FA"))
964
-
965
757
  def browse_files(self):
966
- if not self.conn:
758
+ if not self.db_manager.is_connected():
967
759
  # Create a default in-memory DuckDB connection if none exists
968
- self.conn = duckdb.connect(':memory:')
969
- self.current_db_type = 'duckdb'
970
- self.db_info_label.setText("Connected to: in-memory DuckDB")
760
+ connection_info = self.db_manager.create_memory_connection()
761
+ self.db_info_label.setText(connection_info)
971
762
 
972
763
  file_names, _ = QFileDialog.getOpenFileNames(
973
764
  self,
@@ -978,38 +769,8 @@ class SQLShell(QMainWindow):
978
769
 
979
770
  for file_name in file_names:
980
771
  try:
981
- if file_name.endswith(('.xlsx', '.xls')):
982
- df = pd.read_excel(file_name)
983
- elif file_name.endswith('.csv'):
984
- df = pd.read_csv(file_name)
985
- elif file_name.endswith('.parquet'):
986
- df = pd.read_parquet(file_name)
987
- else:
988
- raise ValueError("Unsupported file format")
989
-
990
- # Generate table name from file name
991
- base_name = os.path.splitext(os.path.basename(file_name))[0]
992
- table_name = self.sanitize_table_name(base_name)
993
-
994
- # Ensure unique table name
995
- original_name = table_name
996
- counter = 1
997
- while table_name in self.loaded_tables:
998
- table_name = f"{original_name}_{counter}"
999
- counter += 1
1000
-
1001
- # Handle table creation based on database type
1002
- if self.current_db_type == 'sqlite':
1003
- # For SQLite, create a table from the DataFrame
1004
- df.to_sql(table_name, self.conn, index=False, if_exists='replace')
1005
- else:
1006
- # For DuckDB, register the DataFrame as a view
1007
- self.conn.register(table_name, df)
1008
-
1009
- self.loaded_tables[table_name] = file_name
1010
-
1011
- # Store column names
1012
- self.table_columns[table_name] = df.columns.tolist()
772
+ # Use the database manager to load the file
773
+ table_name, df = self.db_manager.load_file(file_name)
1013
774
 
1014
775
  # Update UI
1015
776
  self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
@@ -1035,26 +796,11 @@ class SQLShell(QMainWindow):
1035
796
  self.results_table.setColumnCount(0)
1036
797
  self.row_count_label.setText("")
1037
798
 
1038
- def sanitize_table_name(self, name):
1039
- # Replace invalid characters with underscores
1040
- import re
1041
- name = re.sub(r'[^a-zA-Z0-9_]', '_', name)
1042
- # Ensure it starts with a letter
1043
- if not name[0].isalpha():
1044
- name = 'table_' + name
1045
- return name.lower()
1046
-
1047
799
  def remove_selected_table(self):
1048
800
  current_item = self.tables_list.currentItem()
1049
801
  if current_item:
1050
802
  table_name = current_item.text().split(' (')[0]
1051
- if table_name in self.loaded_tables:
1052
- # Remove from DuckDB
1053
- self.conn.execute(f'DROP VIEW IF EXISTS {table_name}')
1054
- # Remove from our tracking
1055
- del self.loaded_tables[table_name]
1056
- if table_name in self.table_columns:
1057
- del self.table_columns[table_name]
803
+ if self.db_manager.remove_table(table_name):
1058
804
  # Remove from list widget
1059
805
  self.tables_list.takeItem(self.tables_list.row(current_item))
1060
806
  self.statusBar().showMessage(f'Removed table "{table_name}"')
@@ -1066,213 +812,380 @@ class SQLShell(QMainWindow):
1066
812
  self.update_completer()
1067
813
 
1068
814
  def open_database(self):
1069
- """Open a database file (DuckDB or SQLite)"""
1070
- file_name, _ = QFileDialog.getOpenFileName(
1071
- self,
1072
- "Open Database File",
1073
- "",
1074
- "Database Files (*.db);;All Files (*)"
1075
- )
1076
-
1077
- if not file_name:
1078
- return
815
+ """Open a database connection with proper error handling and resource management"""
816
+ try:
817
+ filename, _ = QFileDialog.getOpenFileName(
818
+ self,
819
+ "Open Database",
820
+ "",
821
+ "All Database Files (*.db *.sqlite *.sqlite3);;All Files (*)"
822
+ )
1079
823
 
824
+ if filename:
825
+ try:
826
+ # Clear existing database tables from the list widget
827
+ for i in range(self.tables_list.count() - 1, -1, -1):
828
+ item = self.tables_list.item(i)
829
+ if item and item.text().endswith('(database)'):
830
+ self.tables_list.takeItem(i)
831
+
832
+ # Use the database manager to open the database
833
+ self.db_manager.open_database(filename)
834
+
835
+ # Update UI with tables from the database
836
+ for table_name, source in self.db_manager.loaded_tables.items():
837
+ if source == 'database':
838
+ self.tables_list.addItem(f"{table_name} (database)")
839
+
840
+ # Update the completer with table and column names
841
+ self.update_completer()
842
+
843
+ # Update status bar
844
+ self.statusBar().showMessage(f"Connected to database: {filename}")
845
+ self.db_info_label.setText(self.db_manager.get_connection_info())
846
+
847
+ except Exception as e:
848
+ QMessageBox.critical(self, "Database Connection Error",
849
+ f"Failed to open database:\n\n{str(e)}")
850
+ self.statusBar().showMessage("Failed to open database")
851
+
852
+ except Exception as e:
853
+ QMessageBox.critical(self, "Error",
854
+ f"Unexpected error:\n\n{str(e)}")
855
+ self.statusBar().showMessage("Error opening database")
856
+
857
+ def update_completer(self):
858
+ """Update the completer with table and column names in a non-blocking way"""
1080
859
  try:
1081
- # Try to detect database type
1082
- is_sqlite = self.is_sqlite_db(file_name)
860
+ # Check if any tabs exist
861
+ if self.tab_widget.count() == 0:
862
+ return
1083
863
 
1084
- # Close existing connection if any
1085
- if self.conn:
1086
- self.conn.close()
864
+ # Start a background update with a timer
865
+ self.statusBar().showMessage("Updating auto-completion...", 2000)
1087
866
 
1088
- # Connect to the database
1089
- if is_sqlite:
1090
- self.conn = sqlite3.connect(file_name)
1091
- self.current_db_type = 'sqlite'
1092
- else:
1093
- self.conn = duckdb.connect(file_name)
1094
- self.current_db_type = 'duckdb'
867
+ # Track query history and frequently used terms
868
+ if not hasattr(self, 'query_history'):
869
+ self.query_history = []
870
+ self.completion_usage = {} # Track usage frequency
1095
871
 
1096
- # Clear existing tables
1097
- self.loaded_tables.clear()
1098
- self.tables_list.clear()
872
+ # Get completion words from the database manager
873
+ try:
874
+ completion_words = self.db_manager.get_all_table_columns()
875
+ except Exception as e:
876
+ self.statusBar().showMessage(f"Error getting completions: {str(e)}", 2000)
877
+ completion_words = []
878
+
879
+ # Add frequently used terms from query history with higher priority
880
+ if hasattr(self, 'completion_usage') and self.completion_usage:
881
+ # Get the most frequently used terms (top 100)
882
+ frequent_terms = sorted(
883
+ self.completion_usage.items(),
884
+ key=lambda x: x[1],
885
+ reverse=True
886
+ )[:100]
887
+
888
+ # Add these to our completion words
889
+ for term, _ in frequent_terms:
890
+ if term not in completion_words:
891
+ completion_words.append(term)
892
+
893
+ # Limit to a reasonable number of items to prevent performance issues
894
+ MAX_COMPLETION_ITEMS = 2000 # Increased from 1000 to accommodate more smart suggestions
895
+ if len(completion_words) > MAX_COMPLETION_ITEMS:
896
+ # Create a more advanced prioritization strategy
897
+ prioritized_words = []
898
+
899
+ # First, include all table names
900
+ tables = list(self.db_manager.loaded_tables.keys())
901
+ prioritized_words.extend(tables)
902
+
903
+ # Then add most common SQL keywords and patterns
904
+ sql_keywords = [w for w in completion_words if w.isupper() and len(w) > 1]
905
+ prioritized_words.extend(sql_keywords[:200]) # Cap at 200 keywords
906
+
907
+ # Add frequently used items
908
+ if hasattr(self, 'completion_usage'):
909
+ frequent_items = [
910
+ item for item, _ in sorted(
911
+ self.completion_usage.items(),
912
+ key=lambda x: x[1],
913
+ reverse=True
914
+ )[:100] # Top 100 most used
915
+ ]
916
+ prioritized_words.extend(frequent_items)
917
+
918
+ # Add table.column patterns which are very useful
919
+ qualified_columns = [w for w in completion_words if '.' in w and w.split('.')[0] in tables]
920
+ prioritized_words.extend(qualified_columns[:300]) # Cap at 300 qualified columns
921
+
922
+ # Add common completion patterns
923
+ patterns = [w for w in completion_words if ' ' in w] # Spaces indicate phrases/patterns
924
+ prioritized_words.extend(patterns[:200]) # Cap at 200 patterns
925
+
926
+ # Finally add other columns
927
+ remaining_slots = MAX_COMPLETION_ITEMS - len(prioritized_words)
928
+ remaining_words = [
929
+ w for w in completion_words
930
+ if w not in prioritized_words
931
+ and not w.isupper()
932
+ and '.' not in w
933
+ and ' ' not in w
934
+ ]
935
+ prioritized_words.extend(remaining_words[:remaining_slots])
936
+
937
+ # Remove duplicates while preserving order
938
+ seen = set()
939
+ completion_words = []
940
+ for item in prioritized_words:
941
+ if item not in seen:
942
+ seen.add(item)
943
+ completion_words.append(item)
944
+
945
+ # Ensure we don't exceed the maximum
946
+ completion_words = completion_words[:MAX_COMPLETION_ITEMS]
1099
947
 
1100
- # Load tables
1101
- self.load_database_tables()
948
+ # Use a single model for all tabs to save memory and improve performance
949
+ model = QStringListModel(completion_words)
1102
950
 
1103
- # Update UI
1104
- db_type = "SQLite" if is_sqlite else "DuckDB"
1105
- self.db_info_label.setText(f"Connected to: {os.path.basename(file_name)} ({db_type})")
1106
- self.statusBar().showMessage(f'Successfully opened {db_type} database: {file_name}')
951
+ # Keep a reference to the model to prevent garbage collection
952
+ self._current_completer_model = model
1107
953
 
1108
- except Exception as e:
1109
- QMessageBox.critical(self, "Error", f"Failed to open database: {str(e)}")
1110
- self.statusBar().showMessage('Error opening database')
1111
-
1112
- def is_sqlite_db(self, filename):
1113
- """Check if the file is a SQLite database"""
1114
- try:
1115
- with open(filename, 'rb') as f:
1116
- header = f.read(16)
1117
- return header[:16] == b'SQLite format 3\x00'
1118
- except:
1119
- return False
1120
-
1121
- def load_database_tables(self):
1122
- """Load all tables from the current database"""
1123
- try:
1124
- if self.current_db_type == 'sqlite':
1125
- query = "SELECT name FROM sqlite_master WHERE type='table'"
1126
- cursor = self.conn.cursor()
1127
- tables = cursor.execute(query).fetchall()
1128
- for (table_name,) in tables:
1129
- self.loaded_tables[table_name] = 'database'
1130
- self.tables_list.addItem(f"{table_name} (database)")
1131
-
1132
- # Get column names for each table
1133
- try:
1134
- column_query = f"PRAGMA table_info({table_name})"
1135
- columns = cursor.execute(column_query).fetchall()
1136
- self.table_columns[table_name] = [col[1] for col in columns] # Column name is at index 1
1137
- except Exception:
1138
- self.table_columns[table_name] = []
1139
- else: # duckdb
1140
- query = "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
1141
- result = self.conn.execute(query).fetchdf()
1142
- for table_name in result['table_name']:
1143
- self.loaded_tables[table_name] = 'database'
1144
- self.tables_list.addItem(f"{table_name} (database)")
1145
-
1146
- # Get column names for each table
954
+ # Only update the current tab immediately
955
+ current_index = self.tab_widget.currentIndex()
956
+ if current_index >= 0:
957
+ current_tab = self.tab_widget.widget(current_index)
958
+ if current_tab and hasattr(current_tab, 'query_edit'):
1147
959
  try:
1148
- column_query = f"SELECT column_name FROM information_schema.columns WHERE table_name='{table_name}' AND table_schema='main'"
1149
- columns = self.conn.execute(column_query).fetchdf()
1150
- self.table_columns[table_name] = columns['column_name'].tolist()
1151
- except Exception:
1152
- self.table_columns[table_name] = []
1153
-
1154
- # Update the completer with table and column names
1155
- self.update_completer()
960
+ current_tab.query_edit.update_completer_model(model)
961
+ except Exception as e:
962
+ self.statusBar().showMessage(f"Error updating current tab completer: {str(e)}", 2000)
963
+
964
+ # Only schedule updates for additional tabs if we have more than 3 tabs
965
+ # This reduces overhead for common usage patterns
966
+ if self.tab_widget.count() > 1:
967
+ # Calculate a reasonable maximum delay (ms)
968
+ max_delay = min(500, 50 * self.tab_widget.count())
969
+
970
+ # Store timers to prevent garbage collection
971
+ if not hasattr(self, '_completer_timers'):
972
+ self._completer_timers = []
973
+
974
+ # Clear old timers
975
+ for timer in self._completer_timers:
976
+ if timer.isActive():
977
+ timer.stop()
978
+ self._completer_timers = []
979
+
980
+ # Schedule updates for other tabs with increasing delays
981
+ for i in range(self.tab_widget.count()):
982
+ if i != current_index:
983
+ tab = self.tab_widget.widget(i)
984
+ if tab and not tab.isHidden() and hasattr(tab, 'query_edit'):
985
+ delay = int((i + 1) / self.tab_widget.count() * max_delay)
986
+
987
+ timer = QTimer()
988
+ timer.setSingleShot(True)
989
+ # Store tab and model as local variables for the lambda
990
+ # to avoid closure issues
991
+ tab_ref = tab
992
+ model_ref = model
993
+ timer.timeout.connect(
994
+ lambda t=tab_ref, m=model_ref: self._update_tab_completer(t, m))
995
+ self._completer_timers.append(timer)
996
+ timer.start(delay)
997
+
998
+ # Process events to keep UI responsive
999
+ QApplication.processEvents()
1000
+
1001
+ # Return True to indicate success
1002
+ return True
1003
+
1156
1004
  except Exception as e:
1157
- self.statusBar().showMessage(f'Error loading tables: {str(e)}')
1158
-
1159
- def update_completer(self):
1160
- """Update the completer with table and column names"""
1161
- # Collect all table names and column names
1162
- completion_words = list(self.loaded_tables.keys())
1163
-
1164
- # Add column names with table name prefix (for joins)
1165
- for table, columns in self.table_columns.items():
1166
- completion_words.extend(columns)
1167
- completion_words.extend([f"{table}.{col}" for col in columns])
1005
+ # Catch any errors to prevent hanging
1006
+ self.statusBar().showMessage(f"Auto-completion update error: {str(e)}", 2000)
1007
+ return False
1168
1008
 
1169
- # Update the completer in the query editor
1170
- self.query_edit.update_completer_model(completion_words)
1009
+ def _update_tab_completer(self, tab, model):
1010
+ """Helper method to update a tab's completer with the given model"""
1011
+ if tab and not tab.isHidden() and hasattr(tab, 'query_edit'): # Only update visible tabs with query editors
1012
+ try:
1013
+ tab.query_edit.update_completer_model(model)
1014
+ QApplication.processEvents() # Keep UI responsive
1015
+ except Exception as e:
1016
+ print(f"Error updating tab completer: {e}")
1017
+ # Try a simpler approach as fallback
1018
+ try:
1019
+ if hasattr(tab.query_edit, 'all_sql_keywords'):
1020
+ fallback_model = QStringListModel(tab.query_edit.all_sql_keywords)
1021
+ tab.query_edit.completer.setModel(fallback_model)
1022
+ except Exception:
1023
+ pass # Last resort: ignore errors to prevent crashes
1171
1024
 
1172
1025
  def execute_query(self):
1173
- query = self.query_edit.toPlainText().strip()
1174
- if not query:
1175
- return
1176
-
1177
- if not self.conn:
1178
- QMessageBox.warning(self, "No Connection", "No database connection available. Creating an in-memory DuckDB database.")
1179
- self.conn = duckdb.connect(':memory:')
1180
- self.current_db_type = 'duckdb'
1181
- self.db_info_label.setText("Connected to: in-memory DuckDB")
1182
-
1183
- # Show loading indicator in status bar
1184
- self.statusBar().showMessage('Executing query...')
1185
-
1186
1026
  try:
1187
- # Reset results title
1188
- results_title = self.findChild(QLabel, "header_label", Qt.FindChildOption.FindChildrenRecursively)
1189
- if results_title:
1190
- results_title.setText("RESULTS")
1191
-
1192
- if self.current_db_type == 'sqlite':
1193
- # Execute SQLite query and convert to DataFrame
1194
- result = pd.read_sql_query(query, self.conn)
1195
- else:
1196
- # Execute DuckDB query
1197
- result = self.conn.execute(query).fetchdf()
1027
+ # Get the current tab
1028
+ current_tab = self.get_current_tab()
1029
+ if not current_tab:
1030
+ return
1198
1031
 
1199
- self.populate_table(result)
1200
-
1201
- # Show success message with query stats
1202
- execution_time = datetime.now().strftime("%H:%M:%S")
1203
- row_count = len(result)
1204
- self.statusBar().showMessage(f'Query executed successfully at {execution_time} - {row_count:,} rows returned')
1032
+ query = current_tab.get_query_text().strip()
1033
+ if not query:
1034
+ QMessageBox.warning(self, "Empty Query", "Please enter a SQL query to execute.")
1035
+ return
1036
+
1037
+ start_time = datetime.now()
1205
1038
 
1039
+ try:
1040
+ # Use the database manager to execute the query
1041
+ result = self.db_manager.execute_query(query)
1042
+
1043
+ execution_time = (datetime.now() - start_time).total_seconds()
1044
+ self.populate_table(result)
1045
+ self.statusBar().showMessage(f"Query executed successfully. Time: {execution_time:.2f}s. Rows: {len(result)}")
1046
+
1047
+ # Record query in history and update completion usage
1048
+ self._update_query_history(query)
1049
+
1050
+ except SyntaxError as e:
1051
+ QMessageBox.critical(self, "SQL Syntax Error", str(e))
1052
+ self.statusBar().showMessage("Query execution failed: syntax error")
1053
+ except ValueError as e:
1054
+ QMessageBox.critical(self, "Query Error", str(e))
1055
+ self.statusBar().showMessage("Query execution failed")
1056
+ except Exception as e:
1057
+ QMessageBox.critical(self, "Database Error", str(e))
1058
+ self.statusBar().showMessage("Query execution failed")
1059
+
1206
1060
  except Exception as e:
1207
- self.results_table.setRowCount(0)
1208
- self.results_table.setColumnCount(0)
1209
- self.row_count_label.setText("Error")
1210
- self.statusBar().showMessage('Error executing query')
1211
-
1212
- # Show error message with modern styling
1213
- error_msg = str(e)
1214
- if "not found" in error_msg.lower():
1215
- error_msg += "\nMake sure the table name is correct and the table is loaded."
1216
- elif "syntax error" in error_msg.lower():
1217
- error_msg += "\nPlease check your SQL syntax."
1218
-
1219
- error_box = QMessageBox(self)
1220
- error_box.setIcon(QMessageBox.Icon.Critical)
1221
- error_box.setWindowTitle("Query Error")
1222
- error_box.setText("Error executing query")
1223
- error_box.setInformativeText(error_msg)
1224
- error_box.setDetailedText(f"Query:\n{query}")
1225
- error_box.setStandardButtons(QMessageBox.StandardButton.Ok)
1226
- error_box.exec()
1061
+ QMessageBox.critical(self, "Unexpected Error",
1062
+ f"An unexpected error occurred:\n\n{str(e)}")
1063
+ self.statusBar().showMessage("Query execution failed")
1227
1064
 
1065
+ def _update_query_history(self, query):
1066
+ """Update query history and track term usage for improved autocompletion"""
1067
+ import re
1068
+
1069
+ # Initialize history if it doesn't exist
1070
+ if not hasattr(self, 'query_history'):
1071
+ self.query_history = []
1072
+ self.completion_usage = {}
1073
+
1074
+ # Add query to history (limit to 100 queries)
1075
+ self.query_history.append(query)
1076
+ if len(self.query_history) > 100:
1077
+ self.query_history.pop(0)
1078
+
1079
+ # Extract terms and patterns from the query to update usage frequency
1080
+
1081
+ # Extract table and column names
1082
+ table_pattern = r'\b([a-zA-Z0-9_]+)\b\.([a-zA-Z0-9_]+)\b'
1083
+ qualified_columns = re.findall(table_pattern, query)
1084
+ for table, column in qualified_columns:
1085
+ qualified_name = f"{table}.{column}"
1086
+ self.completion_usage[qualified_name] = self.completion_usage.get(qualified_name, 0) + 1
1087
+
1088
+ # Also count the table and column separately
1089
+ self.completion_usage[table] = self.completion_usage.get(table, 0) + 1
1090
+ self.completion_usage[column] = self.completion_usage.get(column, 0) + 1
1091
+
1092
+ # Extract SQL keywords
1093
+ keyword_pattern = r'\b([A-Z_]{2,})\b'
1094
+ keywords = re.findall(keyword_pattern, query.upper())
1095
+ for keyword in keywords:
1096
+ self.completion_usage[keyword] = self.completion_usage.get(keyword, 0) + 1
1097
+
1098
+ # Extract common SQL patterns
1099
+ patterns = [
1100
+ r'(SELECT\s+.*?\s+FROM)',
1101
+ r'(GROUP\s+BY\s+.*?(?:HAVING|ORDER|LIMIT|$))',
1102
+ r'(ORDER\s+BY\s+.*?(?:LIMIT|$))',
1103
+ r'(INNER\s+JOIN|LEFT\s+JOIN|RIGHT\s+JOIN|FULL\s+JOIN).*?ON\s+.*?=\s+.*?(?:WHERE|JOIN|GROUP|ORDER|LIMIT|$)',
1104
+ r'(INSERT\s+INTO\s+.*?\s+VALUES)',
1105
+ r'(UPDATE\s+.*?\s+SET\s+.*?\s+WHERE)',
1106
+ r'(DELETE\s+FROM\s+.*?\s+WHERE)'
1107
+ ]
1108
+
1109
+ for pattern in patterns:
1110
+ matches = re.findall(pattern, query, re.IGNORECASE | re.DOTALL)
1111
+ for match in matches:
1112
+ # Normalize pattern by removing extra whitespace and converting to uppercase
1113
+ normalized = re.sub(r'\s+', ' ', match).strip().upper()
1114
+ if len(normalized) < 50: # Only track reasonably sized patterns
1115
+ self.completion_usage[normalized] = self.completion_usage.get(normalized, 0) + 1
1116
+
1117
+ # Schedule an update of the completion model (but not too often to avoid performance issues)
1118
+ if not hasattr(self, '_last_completer_update') or \
1119
+ (datetime.now() - self._last_completer_update).total_seconds() > 30:
1120
+ self._last_completer_update = datetime.now()
1121
+
1122
+ # Use a timer to delay the update to avoid blocking the UI
1123
+ update_timer = QTimer()
1124
+ update_timer.setSingleShot(True)
1125
+ update_timer.timeout.connect(self.update_completer)
1126
+ update_timer.start(1000) # Update after 1 second
1127
+
1228
1128
  def clear_query(self):
1229
1129
  """Clear the query editor with animation"""
1130
+ # Get the current tab
1131
+ current_tab = self.get_current_tab()
1132
+ if not current_tab:
1133
+ return
1134
+
1230
1135
  # Save current text for animation
1231
- current_text = self.query_edit.toPlainText()
1136
+ current_text = current_tab.get_query_text()
1232
1137
  if not current_text:
1233
1138
  return
1234
1139
 
1235
1140
  # Clear the editor
1236
- self.query_edit.clear()
1141
+ current_tab.set_query_text("")
1237
1142
 
1238
1143
  # Show success message
1239
1144
  self.statusBar().showMessage('Query cleared', 2000) # Show for 2 seconds
1240
1145
 
1241
1146
  def show_table_preview(self, item):
1242
1147
  """Show a preview of the selected table"""
1243
- if item:
1244
- table_name = item.text().split(' (')[0]
1245
- try:
1246
- if self.current_db_type == 'sqlite':
1247
- preview_df = pd.read_sql_query(f'SELECT * FROM "{table_name}" LIMIT 5', self.conn)
1248
- else:
1249
- preview_df = self.conn.execute(f'SELECT * FROM {table_name} LIMIT 5').fetchdf()
1250
-
1251
- self.populate_table(preview_df)
1252
- self.statusBar().showMessage(f'Showing preview of table "{table_name}"')
1253
-
1254
- # Update the results title to show which table is being previewed
1255
- results_title = self.findChild(QLabel, "header_label", Qt.FindChildOption.FindChildrenRecursively)
1256
- if results_title and results_title.text() == "RESULTS":
1257
- results_title.setText(f"PREVIEW: {table_name}")
1148
+ if not item:
1149
+ return
1150
+
1151
+ # Get the current tab
1152
+ current_tab = self.get_current_tab()
1153
+ if not current_tab:
1154
+ return
1155
+
1156
+ table_name = item.text().split(' (')[0]
1157
+ try:
1158
+ # Use the database manager to get a preview of the table
1159
+ preview_df = self.db_manager.get_table_preview(table_name)
1258
1160
 
1259
- except Exception as e:
1260
- self.results_table.setRowCount(0)
1261
- self.results_table.setColumnCount(0)
1262
- self.row_count_label.setText("")
1263
- self.statusBar().showMessage('Error showing table preview')
1264
-
1265
- # Show error message with modern styling
1266
- QMessageBox.critical(
1267
- self,
1268
- "Error",
1269
- f"Error showing preview: {str(e)}",
1270
- QMessageBox.StandardButton.Ok
1271
- )
1161
+ self.populate_table(preview_df)
1162
+ self.statusBar().showMessage(f'Showing preview of table "{table_name}"')
1163
+
1164
+ # Update the results title to show which table is being previewed
1165
+ current_tab.results_title.setText(f"PREVIEW: {table_name}")
1166
+
1167
+ except Exception as e:
1168
+ current_tab.results_table.setRowCount(0)
1169
+ current_tab.results_table.setColumnCount(0)
1170
+ current_tab.row_count_label.setText("")
1171
+ self.statusBar().showMessage('Error showing table preview')
1172
+
1173
+ # Show error message with modern styling
1174
+ QMessageBox.critical(
1175
+ self,
1176
+ "Error",
1177
+ f"Error showing preview: {str(e)}",
1178
+ QMessageBox.StandardButton.Ok
1179
+ )
1272
1180
 
1273
1181
  def load_test_data(self):
1274
1182
  """Generate and load test data"""
1275
1183
  try:
1184
+ # Ensure we have a DuckDB connection
1185
+ if not self.db_manager.is_connected() or self.db_manager.connection_type != 'duckdb':
1186
+ connection_info = self.db_manager.create_memory_connection()
1187
+ self.db_info_label.setText(connection_info)
1188
+
1276
1189
  # Show loading indicator
1277
1190
  self.statusBar().showMessage('Generating test data...')
1278
1191
 
@@ -1289,45 +1202,30 @@ class SQLShell(QMainWindow):
1289
1202
  customer_df.to_parquet('test_data/customer_data.parquet', index=False)
1290
1203
  product_df.to_excel('test_data/product_catalog.xlsx', index=False)
1291
1204
 
1292
- # Load the files into DuckDB
1293
- self.conn.register('sample_sales_data', sales_df)
1294
- self.conn.register('product_catalog', product_df)
1295
- self.conn.register('customer_data', customer_df)
1296
-
1297
- # Update loaded tables tracking
1298
- self.loaded_tables['sample_sales_data'] = 'test_data/sample_sales_data.xlsx'
1299
- self.loaded_tables['product_catalog'] = 'test_data/product_catalog.xlsx'
1300
- self.loaded_tables['customer_data'] = 'test_data/customer_data.parquet'
1301
-
1302
- # Store column names
1303
- self.table_columns['sample_sales_data'] = sales_df.columns.tolist()
1304
- self.table_columns['product_catalog'] = product_df.columns.tolist()
1305
- self.table_columns['customer_data'] = customer_df.columns.tolist()
1205
+ # Register the tables in the database manager
1206
+ self.db_manager.register_dataframe(sales_df, 'sample_sales_data', 'test_data/sample_sales_data.xlsx')
1207
+ self.db_manager.register_dataframe(product_df, 'product_catalog', 'test_data/product_catalog.xlsx')
1208
+ self.db_manager.register_dataframe(customer_df, 'customer_data', 'test_data/customer_data.parquet')
1306
1209
 
1307
1210
  # Update UI
1308
1211
  self.tables_list.clear()
1309
- for table_name, file_path in self.loaded_tables.items():
1212
+ for table_name, file_path in self.db_manager.loaded_tables.items():
1310
1213
  self.tables_list.addItem(f"{table_name} ({os.path.basename(file_path)})")
1311
1214
 
1312
- # Set the sample query
1313
- sample_query = """
1215
+ # Set the sample query in the current tab
1216
+ current_tab = self.get_current_tab()
1217
+ if current_tab:
1218
+ sample_query = """
1314
1219
  SELECT
1315
- s.orderid,
1316
- s.orderdate,
1317
- c.customername,
1318
- p.productname,
1319
- s.quantity,
1320
- s.unitprice,
1321
- (s.quantity * s.unitprice) AS total_amount
1220
+ DISTINCT
1221
+ c.customername
1322
1222
  FROM
1323
1223
  sample_sales_data s
1324
1224
  INNER JOIN customer_data c ON c.customerid = s.customerid
1325
1225
  INNER JOIN product_catalog p ON p.productid = s.productid
1326
- ORDER BY
1327
- s.orderdate DESC
1328
1226
  LIMIT 10
1329
1227
  """
1330
- self.query_edit.setPlainText(sample_query.strip())
1228
+ current_tab.set_query_text(sample_query.strip())
1331
1229
 
1332
1230
  # Update completer
1333
1231
  self.update_completer()
@@ -1343,7 +1241,12 @@ LIMIT 10
1343
1241
  QMessageBox.critical(self, "Error", f"Failed to load test data: {str(e)}")
1344
1242
 
1345
1243
  def export_to_excel(self):
1346
- if self.results_table.rowCount() == 0:
1244
+ # Get the current tab
1245
+ current_tab = self.get_current_tab()
1246
+ if not current_tab:
1247
+ return
1248
+
1249
+ if current_tab.results_table.rowCount() == 0:
1347
1250
  QMessageBox.warning(self, "No Data", "There is no data to export.")
1348
1251
  return
1349
1252
 
@@ -1359,13 +1262,36 @@ LIMIT 10
1359
1262
  df = self.get_table_data_as_dataframe()
1360
1263
  df.to_excel(file_name, index=False)
1361
1264
 
1362
- self.statusBar().showMessage(f'Data exported to {file_name}')
1265
+ # Generate table name from file name
1266
+ base_name = os.path.splitext(os.path.basename(file_name))[0]
1267
+ table_name = self.db_manager.sanitize_table_name(base_name)
1268
+
1269
+ # Ensure unique table name
1270
+ original_name = table_name
1271
+ counter = 1
1272
+ while table_name in self.db_manager.loaded_tables:
1273
+ table_name = f"{original_name}_{counter}"
1274
+ counter += 1
1275
+
1276
+ # Register the table in the database manager
1277
+ self.db_manager.register_dataframe(df, table_name, file_name)
1278
+
1279
+ # Update tracking
1280
+ self.db_manager.loaded_tables[table_name] = file_name
1281
+ self.db_manager.table_columns[table_name] = df.columns.tolist()
1282
+
1283
+ # Update UI
1284
+ self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
1285
+ self.statusBar().showMessage(f'Data exported to {file_name} and loaded as table "{table_name}"')
1286
+
1287
+ # Update completer with new table and column names
1288
+ self.update_completer()
1363
1289
 
1364
1290
  # Show success message
1365
1291
  QMessageBox.information(
1366
1292
  self,
1367
1293
  "Export Successful",
1368
- f"Data has been exported to:\n{file_name}",
1294
+ f"Data has been exported to:\n{file_name}\nand loaded as table: {table_name}",
1369
1295
  QMessageBox.StandardButton.Ok
1370
1296
  )
1371
1297
  except Exception as e:
@@ -1373,7 +1299,12 @@ LIMIT 10
1373
1299
  self.statusBar().showMessage('Error exporting data')
1374
1300
 
1375
1301
  def export_to_parquet(self):
1376
- if self.results_table.rowCount() == 0:
1302
+ # Get the current tab
1303
+ current_tab = self.get_current_tab()
1304
+ if not current_tab:
1305
+ return
1306
+
1307
+ if current_tab.results_table.rowCount() == 0:
1377
1308
  QMessageBox.warning(self, "No Data", "There is no data to export.")
1378
1309
  return
1379
1310
 
@@ -1389,13 +1320,36 @@ LIMIT 10
1389
1320
  df = self.get_table_data_as_dataframe()
1390
1321
  df.to_parquet(file_name, index=False)
1391
1322
 
1392
- self.statusBar().showMessage(f'Data exported to {file_name}')
1323
+ # Generate table name from file name
1324
+ base_name = os.path.splitext(os.path.basename(file_name))[0]
1325
+ table_name = self.db_manager.sanitize_table_name(base_name)
1326
+
1327
+ # Ensure unique table name
1328
+ original_name = table_name
1329
+ counter = 1
1330
+ while table_name in self.db_manager.loaded_tables:
1331
+ table_name = f"{original_name}_{counter}"
1332
+ counter += 1
1333
+
1334
+ # Register the table in the database manager
1335
+ self.db_manager.register_dataframe(df, table_name, file_name)
1336
+
1337
+ # Update tracking
1338
+ self.db_manager.loaded_tables[table_name] = file_name
1339
+ self.db_manager.table_columns[table_name] = df.columns.tolist()
1340
+
1341
+ # Update UI
1342
+ self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
1343
+ self.statusBar().showMessage(f'Data exported to {file_name} and loaded as table "{table_name}"')
1344
+
1345
+ # Update completer with new table and column names
1346
+ self.update_completer()
1393
1347
 
1394
1348
  # Show success message
1395
1349
  QMessageBox.information(
1396
1350
  self,
1397
1351
  "Export Successful",
1398
- f"Data has been exported to:\n{file_name}",
1352
+ f"Data has been exported to:\n{file_name}\nand loaded as table: {table_name}",
1399
1353
  QMessageBox.StandardButton.Ok
1400
1354
  )
1401
1355
  except Exception as e:
@@ -1404,12 +1358,17 @@ LIMIT 10
1404
1358
 
1405
1359
  def get_table_data_as_dataframe(self):
1406
1360
  """Helper function to convert table widget data to a DataFrame"""
1407
- headers = [self.results_table.horizontalHeaderItem(i).text() for i in range(self.results_table.columnCount())]
1361
+ # Get the current tab
1362
+ current_tab = self.get_current_tab()
1363
+ if not current_tab:
1364
+ return pd.DataFrame()
1365
+
1366
+ headers = [current_tab.results_table.horizontalHeaderItem(i).text() for i in range(current_tab.results_table.columnCount())]
1408
1367
  data = []
1409
- for row in range(self.results_table.rowCount()):
1368
+ for row in range(current_tab.results_table.rowCount()):
1410
1369
  row_data = []
1411
- for column in range(self.results_table.columnCount()):
1412
- item = self.results_table.item(row, column)
1370
+ for column in range(current_tab.results_table.columnCount()):
1371
+ item = current_tab.results_table.item(row, column)
1413
1372
  row_data.append(item.text() if item else '')
1414
1373
  data.append(row_data)
1415
1374
  return pd.DataFrame(data, columns=headers)
@@ -1418,56 +1377,1040 @@ LIMIT 10
1418
1377
  """Handle global keyboard shortcuts"""
1419
1378
  # Execute query with Ctrl+Enter or Cmd+Enter (for Mac)
1420
1379
  if event.key() == Qt.Key.Key_Return and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1421
- self.execute_btn.click() # Simply click the button instead of animating
1380
+ self.execute_query()
1422
1381
  return
1423
1382
 
1424
- # Clear query with Ctrl+L
1425
- if event.key() == Qt.Key.Key_L and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1426
- self.clear_btn.click() # Simply click the button instead of animating
1383
+ # Add new tab with Ctrl+T
1384
+ if event.key() == Qt.Key.Key_T and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1385
+ self.add_tab()
1386
+ return
1387
+
1388
+ # Close current tab with Ctrl+W
1389
+ if event.key() == Qt.Key.Key_W and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1390
+ self.close_current_tab()
1391
+ return
1392
+
1393
+ # Duplicate tab with Ctrl+D
1394
+ if event.key() == Qt.Key.Key_D and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1395
+ self.duplicate_current_tab()
1396
+ return
1397
+
1398
+ # Rename tab with Ctrl+R
1399
+ if event.key() == Qt.Key.Key_R and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1400
+ self.rename_current_tab()
1427
1401
  return
1428
1402
 
1429
1403
  super().keyPressEvent(event)
1430
1404
 
1405
+ def closeEvent(self, event):
1406
+ """Ensure proper cleanup of database connections when closing the application"""
1407
+ try:
1408
+ # Check for unsaved changes
1409
+ if self.has_unsaved_changes():
1410
+ reply = QMessageBox.question(self, 'Save Changes',
1411
+ 'Do you want to save your changes before closing?',
1412
+ QMessageBox.StandardButton.Save |
1413
+ QMessageBox.StandardButton.Discard |
1414
+ QMessageBox.StandardButton.Cancel)
1415
+
1416
+ if reply == QMessageBox.StandardButton.Save:
1417
+ self.save_project()
1418
+ elif reply == QMessageBox.StandardButton.Cancel:
1419
+ event.ignore()
1420
+ return
1421
+
1422
+ # Save window state and settings
1423
+ self.save_recent_projects()
1424
+
1425
+ # Close database connections
1426
+ self.db_manager.close_connection()
1427
+ event.accept()
1428
+ except Exception as e:
1429
+ QMessageBox.warning(self, "Cleanup Warning",
1430
+ f"Warning: Could not properly close database connection:\n{str(e)}")
1431
+ event.accept()
1432
+
1433
+ def has_unsaved_changes(self):
1434
+ """Check if there are unsaved changes in the project"""
1435
+ if not self.current_project_file:
1436
+ return (self.tab_widget.count() > 0 and any(self.tab_widget.widget(i).get_query_text().strip()
1437
+ for i in range(self.tab_widget.count()))) or bool(self.db_manager.loaded_tables)
1438
+
1439
+ try:
1440
+ # Load the last saved state
1441
+ with open(self.current_project_file, 'r') as f:
1442
+ saved_data = json.load(f)
1443
+
1444
+ # Prepare current tab data
1445
+ current_tabs_data = []
1446
+ for i in range(self.tab_widget.count()):
1447
+ tab = self.tab_widget.widget(i)
1448
+ tab_data = {
1449
+ 'title': self.tab_widget.tabText(i),
1450
+ 'query': tab.get_query_text()
1451
+ }
1452
+ current_tabs_data.append(tab_data)
1453
+
1454
+ # Compare current state with saved state
1455
+ current_data = {
1456
+ 'tables': {
1457
+ name: {
1458
+ 'file_path': path,
1459
+ 'columns': self.db_manager.table_columns.get(name, [])
1460
+ }
1461
+ for name, path in self.db_manager.loaded_tables.items()
1462
+ },
1463
+ 'tabs': current_tabs_data,
1464
+ 'connection_type': self.db_manager.connection_type
1465
+ }
1466
+
1467
+ # Compare tables and connection type
1468
+ if (current_data['connection_type'] != saved_data.get('connection_type') or
1469
+ len(current_data['tables']) != len(saved_data.get('tables', {}))):
1470
+ return True
1471
+
1472
+ # Compare tab data
1473
+ if 'tabs' not in saved_data or len(current_data['tabs']) != len(saved_data['tabs']):
1474
+ return True
1475
+
1476
+ for i, tab_data in enumerate(current_data['tabs']):
1477
+ saved_tab = saved_data['tabs'][i]
1478
+ if (tab_data['title'] != saved_tab.get('title', '') or
1479
+ tab_data['query'] != saved_tab.get('query', '')):
1480
+ return True
1481
+
1482
+ # If we get here, everything matches
1483
+ return False
1484
+
1485
+ except Exception:
1486
+ # If there's any error reading the saved file, assume there are unsaved changes
1487
+ return True
1488
+
1489
+ def show_tables_context_menu(self, position):
1490
+ """Show context menu for tables list"""
1491
+ item = self.tables_list.itemAt(position)
1492
+ if not item:
1493
+ return
1494
+
1495
+ # Get current tab
1496
+ current_tab = self.get_current_tab()
1497
+ if not current_tab:
1498
+ return
1499
+
1500
+ # Get table name without the file info in parentheses
1501
+ table_name = item.text().split(' (')[0]
1502
+
1503
+ # Create context menu
1504
+ context_menu = QMenu(self)
1505
+ context_menu.setStyleSheet("""
1506
+ QMenu {
1507
+ background-color: white;
1508
+ border: 1px solid #BDC3C7;
1509
+ padding: 5px;
1510
+ }
1511
+ QMenu::item {
1512
+ padding: 5px 20px;
1513
+ }
1514
+ QMenu::item:selected {
1515
+ background-color: #3498DB;
1516
+ color: white;
1517
+ }
1518
+ """)
1519
+
1520
+ # Add menu actions
1521
+ select_from_action = context_menu.addAction("Select from")
1522
+ add_to_editor_action = context_menu.addAction("Just add to editor")
1523
+ context_menu.addSeparator()
1524
+ rename_action = context_menu.addAction("Rename table...")
1525
+ delete_action = context_menu.addAction("Delete table")
1526
+ delete_action.setIcon(QIcon.fromTheme("edit-delete"))
1527
+
1528
+ # Show menu and get selected action
1529
+ action = context_menu.exec(self.tables_list.mapToGlobal(position))
1530
+
1531
+ if action == select_from_action:
1532
+ # Insert "SELECT * FROM table_name" at cursor position
1533
+ cursor = current_tab.query_edit.textCursor()
1534
+ cursor.insertText(f"SELECT * FROM {table_name}")
1535
+ current_tab.query_edit.setFocus()
1536
+ elif action == add_to_editor_action:
1537
+ # Just insert the table name at cursor position
1538
+ cursor = current_tab.query_edit.textCursor()
1539
+ cursor.insertText(table_name)
1540
+ current_tab.query_edit.setFocus()
1541
+ elif action == rename_action:
1542
+ # Show rename dialog
1543
+ new_name, ok = QInputDialog.getText(
1544
+ self,
1545
+ "Rename Table",
1546
+ "Enter new table name:",
1547
+ QLineEdit.EchoMode.Normal,
1548
+ table_name
1549
+ )
1550
+ if ok and new_name:
1551
+ if self.rename_table(table_name, new_name):
1552
+ # Update the item text
1553
+ source = item.text().split(' (')[1][:-1] # Get the source part
1554
+ item.setText(f"{new_name} ({source})")
1555
+ self.statusBar().showMessage(f'Table renamed to "{new_name}"')
1556
+ elif action == delete_action:
1557
+ # Show confirmation dialog
1558
+ reply = QMessageBox.question(
1559
+ self,
1560
+ "Delete Table",
1561
+ f"Are you sure you want to delete table '{table_name}'?",
1562
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
1563
+ )
1564
+ if reply == QMessageBox.StandardButton.Yes:
1565
+ self.remove_selected_table()
1566
+
1567
+ def new_project(self):
1568
+ """Create a new project by clearing current state"""
1569
+ if self.db_manager.is_connected():
1570
+ reply = QMessageBox.question(self, 'New Project',
1571
+ 'Are you sure you want to start a new project? All unsaved changes will be lost.',
1572
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1573
+ if reply == QMessageBox.StandardButton.Yes:
1574
+ # Close existing connection
1575
+ self.db_manager.close_connection()
1576
+
1577
+ # Reset state
1578
+ self.tables_list.clear()
1579
+
1580
+ # Clear all tabs except one
1581
+ while self.tab_widget.count() > 1:
1582
+ self.close_tab(1) # Always close tab at index 1 to keep at least one tab
1583
+
1584
+ # Clear the remaining tab
1585
+ first_tab = self.get_tab_at_index(0)
1586
+ if first_tab:
1587
+ first_tab.set_query_text("")
1588
+ first_tab.results_table.setRowCount(0)
1589
+ first_tab.results_table.setColumnCount(0)
1590
+ first_tab.row_count_label.setText("")
1591
+ first_tab.results_title.setText("RESULTS")
1592
+
1593
+ self.current_project_file = None
1594
+ self.setWindowTitle('SQL Shell')
1595
+ self.db_info_label.setText("No database connected")
1596
+ self.statusBar().showMessage('New project created')
1597
+
1598
+ def save_project(self):
1599
+ """Save the current project"""
1600
+ if not self.current_project_file:
1601
+ self.save_project_as()
1602
+ return
1603
+
1604
+ self.save_project_to_file(self.current_project_file)
1605
+
1606
+ def save_project_as(self):
1607
+ """Save the current project to a new file"""
1608
+ file_name, _ = QFileDialog.getSaveFileName(
1609
+ self,
1610
+ "Save Project",
1611
+ "",
1612
+ "SQL Shell Project (*.sqls);;All Files (*)"
1613
+ )
1614
+
1615
+ if file_name:
1616
+ if not file_name.endswith('.sqls'):
1617
+ file_name += '.sqls'
1618
+ self.save_project_to_file(file_name)
1619
+ self.current_project_file = file_name
1620
+ self.setWindowTitle(f'SQL Shell - {os.path.basename(file_name)}')
1621
+
1622
+ def save_project_to_file(self, file_name):
1623
+ """Save project data to a file"""
1624
+ try:
1625
+ # Save tab information
1626
+ tabs_data = []
1627
+ for i in range(self.tab_widget.count()):
1628
+ tab = self.tab_widget.widget(i)
1629
+ tab_data = {
1630
+ 'title': self.tab_widget.tabText(i),
1631
+ 'query': tab.get_query_text()
1632
+ }
1633
+ tabs_data.append(tab_data)
1634
+
1635
+ project_data = {
1636
+ 'tables': {},
1637
+ 'tabs': tabs_data,
1638
+ 'connection_type': self.db_manager.connection_type,
1639
+ 'database_path': None # Initialize to None
1640
+ }
1641
+
1642
+ # If we have a database connection, save the path
1643
+ if self.db_manager.is_connected() and hasattr(self.db_manager, 'database_path'):
1644
+ project_data['database_path'] = self.db_manager.database_path
1645
+
1646
+ # Save table information
1647
+ for table_name, file_path in self.db_manager.loaded_tables.items():
1648
+ # For database tables and query results, store the special identifier
1649
+ if file_path in ['database', 'query_result']:
1650
+ source_path = file_path
1651
+ else:
1652
+ # For file-based tables, store the absolute path
1653
+ source_path = os.path.abspath(file_path)
1654
+
1655
+ project_data['tables'][table_name] = {
1656
+ 'file_path': source_path,
1657
+ 'columns': self.db_manager.table_columns.get(table_name, [])
1658
+ }
1659
+
1660
+ with open(file_name, 'w') as f:
1661
+ json.dump(project_data, f, indent=4)
1662
+
1663
+ # Add to recent projects
1664
+ self.add_recent_project(os.path.abspath(file_name))
1665
+
1666
+ self.statusBar().showMessage(f'Project saved to {file_name}')
1667
+
1668
+ except Exception as e:
1669
+ QMessageBox.critical(self, "Error",
1670
+ f"Failed to save project:\n\n{str(e)}")
1671
+
1672
+ def open_project(self, file_name=None):
1673
+ """Open a project file"""
1674
+ if not file_name:
1675
+ file_name, _ = QFileDialog.getOpenFileName(
1676
+ self,
1677
+ "Open Project",
1678
+ "",
1679
+ "SQL Shell Project (*.sqls);;All Files (*)"
1680
+ )
1681
+
1682
+ if file_name:
1683
+ try:
1684
+ # Create a progress dialog to keep UI responsive
1685
+ progress = QProgressDialog("Loading project...", "Cancel", 0, 100, self)
1686
+ progress.setWindowTitle("Opening Project")
1687
+ progress.setWindowModality(Qt.WindowModality.WindowModal)
1688
+ progress.setMinimumDuration(500) # Show after 500ms delay
1689
+ progress.setValue(0)
1690
+
1691
+ # Load project data
1692
+ with open(file_name, 'r') as f:
1693
+ project_data = json.load(f)
1694
+
1695
+ # Update progress
1696
+ progress.setValue(10)
1697
+ QApplication.processEvents()
1698
+
1699
+ # Start fresh
1700
+ self.new_project()
1701
+ progress.setValue(15)
1702
+ QApplication.processEvents()
1703
+
1704
+ # Check if there's a database path in the project
1705
+ has_database_path = 'database_path' in project_data and project_data['database_path']
1706
+ has_database_tables = any(table_info.get('file_path') == 'database'
1707
+ for table_info in project_data.get('tables', {}).values())
1708
+
1709
+ # Set a flag to track if database tables are loaded
1710
+ database_tables_loaded = False
1711
+
1712
+ # If the project contains database tables and a database path, try to connect to it
1713
+ progress.setLabelText("Connecting to database...")
1714
+ if has_database_path and has_database_tables:
1715
+ database_path = project_data['database_path']
1716
+ try:
1717
+ if os.path.exists(database_path):
1718
+ # Connect to the database
1719
+ self.db_manager.open_database(database_path)
1720
+ self.db_info_label.setText(self.db_manager.get_connection_info())
1721
+ self.statusBar().showMessage(f"Connected to database: {database_path}")
1722
+
1723
+ # Add all database tables to the tables list
1724
+ for table_name, source in self.db_manager.loaded_tables.items():
1725
+ if source == 'database':
1726
+ self.tables_list.addItem(f"{table_name} (database)")
1727
+
1728
+ # Mark database tables as loaded
1729
+ database_tables_loaded = True
1730
+ else:
1731
+ database_tables_loaded = False
1732
+ QMessageBox.warning(self, "Database Not Found",
1733
+ f"The project's database file was not found at:\n{database_path}\n\n"
1734
+ "Database tables will be shown but not accessible until you reconnect to the database.")
1735
+ except Exception as e:
1736
+ database_tables_loaded = False
1737
+ QMessageBox.warning(self, "Database Connection Error",
1738
+ f"Failed to connect to the project's database:\n{str(e)}\n\n"
1739
+ "Database tables will be shown but not accessible until you reconnect to the database.")
1740
+ else:
1741
+ # Create connection if needed (we don't have a specific database to connect to)
1742
+ database_tables_loaded = False
1743
+ if not self.db_manager.is_connected():
1744
+ connection_info = self.db_manager.create_memory_connection()
1745
+ self.db_info_label.setText(connection_info)
1746
+ elif 'connection_type' in project_data and project_data['connection_type'] != self.db_manager.connection_type:
1747
+ # If connected but with a different database type than what was saved in the project
1748
+ QMessageBox.warning(self, "Database Type Mismatch",
1749
+ f"The project was saved with a {project_data['connection_type']} database, but you're currently using {self.db_manager.connection_type}.\n\n"
1750
+ "Some database-specific features may not work correctly. Consider reconnecting to the correct database type.")
1751
+
1752
+ progress.setValue(30)
1753
+ QApplication.processEvents()
1754
+
1755
+ # Calculate progress steps for loading tables
1756
+ table_count = len(project_data.get('tables', {}))
1757
+ table_progress_start = 30
1758
+ table_progress_end = 70
1759
+ table_progress_step = (table_progress_end - table_progress_start) / max(1, table_count)
1760
+ current_progress = table_progress_start
1761
+
1762
+ # Load tables
1763
+ for table_name, table_info in project_data.get('tables', {}).items():
1764
+ if progress.wasCanceled():
1765
+ break
1766
+
1767
+ progress.setLabelText(f"Loading table: {table_name}")
1768
+ file_path = table_info['file_path']
1769
+ try:
1770
+ if file_path == 'database':
1771
+ # Skip if we already loaded database tables by connecting to the database
1772
+ if database_tables_loaded:
1773
+ continue
1774
+
1775
+ # For database tables, we need to check if the original database is connected
1776
+ # Don't try to SELECT from non-existent tables
1777
+ # Instead just register the table name for UI display
1778
+ self.db_manager.loaded_tables[table_name] = 'database'
1779
+
1780
+ # If we have column information, use it
1781
+ if 'columns' in table_info:
1782
+ self.db_manager.table_columns[table_name] = table_info['columns']
1783
+
1784
+ # Add to the UI list
1785
+ self.tables_list.addItem(f"{table_name} (database)")
1786
+ elif file_path == 'query_result':
1787
+ # For tables from query results, we'll need to re-run the query
1788
+ # For now, just note it as a query result table
1789
+ self.db_manager.loaded_tables[table_name] = 'query_result'
1790
+ self.tables_list.addItem(f"{table_name} (query result)")
1791
+ elif os.path.exists(file_path):
1792
+ # Use the database manager to load the file
1793
+ try:
1794
+ loaded_table_name, df = self.db_manager.load_file(file_path)
1795
+ self.tables_list.addItem(f"{loaded_table_name} ({os.path.basename(file_path)})")
1796
+ except Exception as e:
1797
+ QMessageBox.warning(self, "Warning",
1798
+ f"Failed to load file for table {table_name}:\n{str(e)}")
1799
+ continue
1800
+ else:
1801
+ QMessageBox.warning(self, "Warning",
1802
+ f"Could not find file for table {table_name}: {file_path}")
1803
+ continue
1804
+
1805
+ except Exception as e:
1806
+ QMessageBox.warning(self, "Warning",
1807
+ f"Failed to load table {table_name}:\n{str(e)}")
1808
+
1809
+ # Update progress for this table
1810
+ current_progress += table_progress_step
1811
+ progress.setValue(int(current_progress))
1812
+ QApplication.processEvents() # Keep UI responsive
1813
+
1814
+ # If the project had database tables but we couldn't connect automatically, notify the user
1815
+ if has_database_tables and not database_tables_loaded:
1816
+ QMessageBox.information(self, "Database Connection Required",
1817
+ "This project contains database tables. You need to reconnect to the database to use them.\n\n"
1818
+ "Use the 'Open Database' button to connect to your database file.")
1819
+
1820
+ # Check if the operation was canceled
1821
+ if progress.wasCanceled():
1822
+ self.statusBar().showMessage("Project loading was canceled")
1823
+ progress.close()
1824
+ return
1825
+
1826
+ progress.setValue(75)
1827
+ progress.setLabelText("Setting up tabs...")
1828
+ QApplication.processEvents()
1829
+
1830
+ # Load tabs in a more efficient way
1831
+ if 'tabs' in project_data and project_data['tabs']:
1832
+ try:
1833
+ # Temporarily disable signals
1834
+ self.tab_widget.blockSignals(True)
1835
+
1836
+ # First, pre-remove any existing tabs
1837
+ while self.tab_widget.count() > 0:
1838
+ widget = self.tab_widget.widget(0)
1839
+ self.tab_widget.removeTab(0)
1840
+ if widget in self.tabs:
1841
+ self.tabs.remove(widget)
1842
+ widget.deleteLater()
1843
+
1844
+ # Then create all tab widgets at once (empty)
1845
+ tab_count = len(project_data['tabs'])
1846
+ tab_progress_step = 15 / max(1, tab_count)
1847
+ progress.setValue(80)
1848
+ QApplication.processEvents()
1849
+
1850
+ # Create all tab widgets first without setting content
1851
+ for i, tab_data in enumerate(project_data['tabs']):
1852
+ # Create a new tab
1853
+ tab = QueryTab(self)
1854
+ self.tabs.append(tab)
1855
+
1856
+ # Add to tab widget
1857
+ title = tab_data.get('title', f'Query {i+1}')
1858
+ self.tab_widget.addTab(tab, title)
1859
+
1860
+ progress.setValue(int(80 + i * tab_progress_step/2))
1861
+ QApplication.processEvents()
1862
+
1863
+ # Now set the content for each tab
1864
+ for i, tab_data in enumerate(project_data['tabs']):
1865
+ # Get the tab and set its query text
1866
+ tab = self.tab_widget.widget(i)
1867
+ if tab and 'query' in tab_data:
1868
+ tab.set_query_text(tab_data['query'])
1869
+
1870
+ progress.setValue(int(87 + i * tab_progress_step/2))
1871
+ QApplication.processEvents()
1872
+
1873
+ # Re-enable signals
1874
+ self.tab_widget.blockSignals(False)
1875
+
1876
+ # Set current tab
1877
+ if self.tab_widget.count() > 0:
1878
+ self.tab_widget.setCurrentIndex(0)
1879
+
1880
+ except Exception as e:
1881
+ # If there's an error, ensure we restore signals
1882
+ self.tab_widget.blockSignals(False)
1883
+ self.statusBar().showMessage(f"Error loading tabs: {str(e)}")
1884
+ # Create a single default tab if all fails
1885
+ if self.tab_widget.count() == 0:
1886
+ self.add_tab()
1887
+ else:
1888
+ # Create default tab if no tabs in project
1889
+ self.add_tab()
1890
+
1891
+ progress.setValue(90)
1892
+ progress.setLabelText("Finishing up...")
1893
+ QApplication.processEvents()
1894
+
1895
+ # Update UI
1896
+ self.current_project_file = file_name
1897
+ self.setWindowTitle(f'SQL Shell - {os.path.basename(file_name)}')
1898
+
1899
+ # Add to recent projects
1900
+ self.add_recent_project(os.path.abspath(file_name))
1901
+
1902
+ # Defer the auto-completer update to after loading is complete
1903
+ # This helps prevent UI freezing during project loading
1904
+ progress.setValue(95)
1905
+ QApplication.processEvents()
1906
+
1907
+ # Use a timer to update the completer after the UI is responsive
1908
+ complete_timer = QTimer()
1909
+ complete_timer.setSingleShot(True)
1910
+ complete_timer.timeout.connect(self.update_completer)
1911
+ complete_timer.start(100) # Short delay before updating completer
1912
+
1913
+ # Queue another update for reliability - sometimes the first update might not fully complete
1914
+ failsafe_timer = QTimer()
1915
+ failsafe_timer.setSingleShot(True)
1916
+ failsafe_timer.timeout.connect(self.update_completer)
1917
+ failsafe_timer.start(2000) # Try again after 2 seconds to ensure completion is loaded
1918
+
1919
+ progress.setValue(100)
1920
+ QApplication.processEvents()
1921
+
1922
+ self.statusBar().showMessage(f'Project loaded from {file_name}')
1923
+ progress.close()
1924
+
1925
+ except Exception as e:
1926
+ QMessageBox.critical(self, "Error",
1927
+ f"Failed to open project:\n\n{str(e)}")
1928
+
1929
+ def rename_table(self, old_name, new_name):
1930
+ """Rename a table in the database and update tracking"""
1931
+ try:
1932
+ # Use the database manager to rename the table
1933
+ result = self.db_manager.rename_table(old_name, new_name)
1934
+
1935
+ if result:
1936
+ # Update completer
1937
+ self.update_completer()
1938
+ return True
1939
+
1940
+ return False
1941
+
1942
+ except Exception as e:
1943
+ QMessageBox.critical(self, "Error", f"Failed to rename table:\n\n{str(e)}")
1944
+ return False
1945
+
1946
+ def load_recent_projects(self):
1947
+ """Load recent projects from settings file"""
1948
+ try:
1949
+ settings_file = os.path.join(os.path.expanduser('~'), '.sqlshell_settings.json')
1950
+ if os.path.exists(settings_file):
1951
+ with open(settings_file, 'r') as f:
1952
+ settings = json.load(f)
1953
+ self.recent_projects = settings.get('recent_projects', [])
1954
+
1955
+ # Load window settings if available
1956
+ window_settings = settings.get('window', {})
1957
+ if window_settings:
1958
+ self.restore_window_state(window_settings)
1959
+ except Exception:
1960
+ self.recent_projects = []
1961
+
1962
+ def save_recent_projects(self):
1963
+ """Save recent projects to settings file"""
1964
+ try:
1965
+ settings_file = os.path.join(os.path.expanduser('~'), '.sqlshell_settings.json')
1966
+ settings = {}
1967
+ if os.path.exists(settings_file):
1968
+ with open(settings_file, 'r') as f:
1969
+ settings = json.load(f)
1970
+ settings['recent_projects'] = self.recent_projects
1971
+
1972
+ # Save window settings
1973
+ window_settings = self.save_window_state()
1974
+ settings['window'] = window_settings
1975
+
1976
+ with open(settings_file, 'w') as f:
1977
+ json.dump(settings, f, indent=4)
1978
+ except Exception as e:
1979
+ print(f"Error saving recent projects: {e}")
1980
+
1981
+ def save_window_state(self):
1982
+ """Save current window state"""
1983
+ window_settings = {
1984
+ 'maximized': self.isMaximized(),
1985
+ 'geometry': {
1986
+ 'x': self.geometry().x(),
1987
+ 'y': self.geometry().y(),
1988
+ 'width': self.geometry().width(),
1989
+ 'height': self.geometry().height()
1990
+ }
1991
+ }
1992
+ return window_settings
1993
+
1994
+ def restore_window_state(self, window_settings):
1995
+ """Restore window state from settings"""
1996
+ try:
1997
+ # Check if we have valid geometry settings
1998
+ geometry = window_settings.get('geometry', {})
1999
+ if all(key in geometry for key in ['x', 'y', 'width', 'height']):
2000
+ x, y = geometry['x'], geometry['y']
2001
+ width, height = geometry['width'], geometry['height']
2002
+
2003
+ # Ensure the window is visible on the current screen
2004
+ screen = QApplication.primaryScreen()
2005
+ screen_geometry = screen.availableGeometry()
2006
+
2007
+ # Adjust if window would be off-screen
2008
+ if x < 0 or x + 100 > screen_geometry.width():
2009
+ x = 100
2010
+ if y < 0 or y + 100 > screen_geometry.height():
2011
+ y = 100
2012
+
2013
+ # Adjust if window is too large for the current screen
2014
+ if width > screen_geometry.width():
2015
+ width = int(screen_geometry.width() * 0.85)
2016
+ if height > screen_geometry.height():
2017
+ height = int(screen_geometry.height() * 0.85)
2018
+
2019
+ self.setGeometry(x, y, width, height)
2020
+
2021
+ # Set maximized state if needed
2022
+ if window_settings.get('maximized', False):
2023
+ self.showMaximized()
2024
+ self.was_maximized = True
2025
+
2026
+ except Exception as e:
2027
+ print(f"Error restoring window state: {e}")
2028
+ # Fall back to default geometry
2029
+ screen = QApplication.primaryScreen()
2030
+ screen_geometry = screen.availableGeometry()
2031
+ self.setGeometry(100, 100,
2032
+ min(1400, int(screen_geometry.width() * 0.85)),
2033
+ min(800, int(screen_geometry.height() * 0.85)))
2034
+
2035
+ def add_recent_project(self, project_path):
2036
+ """Add a project to recent projects list"""
2037
+ if project_path in self.recent_projects:
2038
+ self.recent_projects.remove(project_path)
2039
+ self.recent_projects.insert(0, project_path)
2040
+ self.recent_projects = self.recent_projects[:self.max_recent_projects]
2041
+ self.save_recent_projects()
2042
+ self.update_recent_projects_menu()
2043
+
2044
+ def update_recent_projects_menu(self):
2045
+ """Update the recent projects menu"""
2046
+ self.recent_projects_menu.clear()
2047
+
2048
+ if not self.recent_projects:
2049
+ no_recent = self.recent_projects_menu.addAction("No Recent Projects")
2050
+ no_recent.setEnabled(False)
2051
+ return
2052
+
2053
+ for project_path in self.recent_projects:
2054
+ if os.path.exists(project_path):
2055
+ action = self.recent_projects_menu.addAction(os.path.basename(project_path))
2056
+ action.setData(project_path)
2057
+ action.triggered.connect(lambda checked, path=project_path: self.open_recent_project(path))
2058
+
2059
+ if self.recent_projects:
2060
+ self.recent_projects_menu.addSeparator()
2061
+ clear_action = self.recent_projects_menu.addAction("Clear Recent Projects")
2062
+ clear_action.triggered.connect(self.clear_recent_projects)
2063
+
2064
+ def open_recent_project(self, project_path):
2065
+ """Open a project from the recent projects list"""
2066
+ if os.path.exists(project_path):
2067
+ self.current_project_file = project_path
2068
+ self.open_project(project_path)
2069
+ else:
2070
+ QMessageBox.warning(self, "Warning",
2071
+ f"Project file not found:\n{project_path}")
2072
+ self.recent_projects.remove(project_path)
2073
+ self.save_recent_projects()
2074
+ self.update_recent_projects_menu()
2075
+
2076
+ def clear_recent_projects(self):
2077
+ """Clear the list of recent projects"""
2078
+ self.recent_projects.clear()
2079
+ self.save_recent_projects()
2080
+ self.update_recent_projects_menu()
2081
+
2082
+ def add_tab(self, title="Query 1"):
2083
+ """Add a new query tab"""
2084
+ # Ensure title is a string
2085
+ title = str(title)
2086
+
2087
+ # Create a new tab with a unique name if needed
2088
+ if title == "Query 1" and self.tab_widget.count() > 0:
2089
+ # Generate a unique tab name (Query 2, Query 3, etc.)
2090
+ # Use a more efficient approach to find a unique name
2091
+ base_name = "Query"
2092
+ existing_names = set()
2093
+
2094
+ # Collect existing tab names first (more efficient than checking each time)
2095
+ for i in range(self.tab_widget.count()):
2096
+ existing_names.add(self.tab_widget.tabText(i))
2097
+
2098
+ # Find the next available number
2099
+ counter = 1
2100
+ while f"{base_name} {counter}" in existing_names:
2101
+ counter += 1
2102
+ title = f"{base_name} {counter}"
2103
+
2104
+ # Create the tab content
2105
+ tab = QueryTab(self)
2106
+
2107
+ # Add to our list of tabs
2108
+ self.tabs.append(tab)
2109
+
2110
+ # Block signals temporarily to improve performance when adding many tabs
2111
+ was_blocked = self.tab_widget.blockSignals(True)
2112
+
2113
+ # Add tab to widget
2114
+ index = self.tab_widget.addTab(tab, title)
2115
+ self.tab_widget.setCurrentIndex(index)
2116
+
2117
+ # Restore signals
2118
+ self.tab_widget.blockSignals(was_blocked)
2119
+
2120
+ # Focus the new tab's query editor
2121
+ tab.query_edit.setFocus()
2122
+
2123
+ # Process events to keep UI responsive
2124
+ QApplication.processEvents()
2125
+
2126
+ return tab
2127
+
2128
+ def duplicate_current_tab(self):
2129
+ """Duplicate the current tab"""
2130
+ if self.tab_widget.count() == 0:
2131
+ return self.add_tab()
2132
+
2133
+ current_idx = self.tab_widget.currentIndex()
2134
+ if current_idx == -1:
2135
+ return
2136
+
2137
+ # Get current tab data
2138
+ current_tab = self.get_current_tab()
2139
+ current_title = self.tab_widget.tabText(current_idx)
2140
+
2141
+ # Create a new tab with "(Copy)" suffix
2142
+ new_title = f"{current_title} (Copy)"
2143
+ new_tab = self.add_tab(new_title)
2144
+
2145
+ # Copy query text
2146
+ new_tab.set_query_text(current_tab.get_query_text())
2147
+
2148
+ # Return focus to the new tab
2149
+ new_tab.query_edit.setFocus()
2150
+
2151
+ return new_tab
2152
+
2153
+ def rename_current_tab(self):
2154
+ """Rename the current tab"""
2155
+ current_idx = self.tab_widget.currentIndex()
2156
+ if current_idx == -1:
2157
+ return
2158
+
2159
+ current_title = self.tab_widget.tabText(current_idx)
2160
+
2161
+ new_title, ok = QInputDialog.getText(
2162
+ self,
2163
+ "Rename Tab",
2164
+ "Enter new tab name:",
2165
+ QLineEdit.EchoMode.Normal,
2166
+ current_title
2167
+ )
2168
+
2169
+ if ok and new_title:
2170
+ self.tab_widget.setTabText(current_idx, new_title)
2171
+
2172
+ def handle_tab_double_click(self, index):
2173
+ """Handle double-clicking on a tab by starting rename immediately"""
2174
+ if index == -1:
2175
+ return
2176
+
2177
+ current_title = self.tab_widget.tabText(index)
2178
+
2179
+ new_title, ok = QInputDialog.getText(
2180
+ self,
2181
+ "Rename Tab",
2182
+ "Enter new tab name:",
2183
+ QLineEdit.EchoMode.Normal,
2184
+ current_title
2185
+ )
2186
+
2187
+ if ok and new_title:
2188
+ self.tab_widget.setTabText(index, new_title)
2189
+
2190
+ def close_tab(self, index):
2191
+ """Close the tab at the given index"""
2192
+ if self.tab_widget.count() <= 1:
2193
+ # Don't close the last tab, just clear it
2194
+ tab = self.get_tab_at_index(index)
2195
+ if tab:
2196
+ tab.set_query_text("")
2197
+ tab.results_table.clearContents()
2198
+ tab.results_table.setRowCount(0)
2199
+ tab.results_table.setColumnCount(0)
2200
+ return
2201
+
2202
+ # Block signals temporarily to improve performance when removing multiple tabs
2203
+ was_blocked = self.tab_widget.blockSignals(True)
2204
+
2205
+ # Remove the tab
2206
+ widget = self.tab_widget.widget(index)
2207
+ self.tab_widget.removeTab(index)
2208
+
2209
+ # Restore signals
2210
+ self.tab_widget.blockSignals(was_blocked)
2211
+
2212
+ # Remove from our list of tabs
2213
+ if widget in self.tabs:
2214
+ self.tabs.remove(widget)
2215
+
2216
+ # Schedule the widget for deletion instead of immediate deletion
2217
+ widget.deleteLater()
2218
+
2219
+ # Process events to keep UI responsive
2220
+ QApplication.processEvents()
2221
+
2222
+ def close_current_tab(self):
2223
+ """Close the current tab"""
2224
+ current_idx = self.tab_widget.currentIndex()
2225
+ if current_idx != -1:
2226
+ self.close_tab(current_idx)
2227
+
2228
+ def get_current_tab(self):
2229
+ """Get the currently active tab"""
2230
+ current_idx = self.tab_widget.currentIndex()
2231
+ if current_idx == -1:
2232
+ return None
2233
+ return self.tab_widget.widget(current_idx)
2234
+
2235
+ def get_tab_at_index(self, index):
2236
+ """Get the tab at the specified index"""
2237
+ if index < 0 or index >= self.tab_widget.count():
2238
+ return None
2239
+ return self.tab_widget.widget(index)
2240
+
2241
+ def toggle_maximize_window(self):
2242
+ """Toggle between maximized and normal window state"""
2243
+ if self.isMaximized():
2244
+ self.showNormal()
2245
+ self.was_maximized = False
2246
+ else:
2247
+ self.showMaximized()
2248
+ self.was_maximized = True
2249
+
2250
+ def change_zoom(self, factor):
2251
+ """Change the zoom level of the application by adjusting font sizes"""
2252
+ try:
2253
+ # Update font sizes for SQL editors
2254
+ for i in range(self.tab_widget.count()):
2255
+ tab = self.tab_widget.widget(i)
2256
+ if hasattr(tab, 'query_edit'):
2257
+ # Get current font
2258
+ current_font = tab.query_edit.font()
2259
+ current_size = current_font.pointSizeF()
2260
+
2261
+ # Calculate new size with limits to prevent too small/large fonts
2262
+ new_size = current_size * factor
2263
+ if 6 <= new_size <= 72: # Reasonable limits
2264
+ current_font.setPointSizeF(new_size)
2265
+ tab.query_edit.setFont(current_font)
2266
+
2267
+ # Also update the line number area
2268
+ tab.query_edit.update_line_number_area_width(0)
2269
+
2270
+ # Update results table font if needed
2271
+ if hasattr(tab, 'results_table'):
2272
+ table_font = tab.results_table.font()
2273
+ table_size = table_font.pointSizeF()
2274
+ new_table_size = table_size * factor
2275
+
2276
+ if 6 <= new_table_size <= 72:
2277
+ table_font.setPointSizeF(new_table_size)
2278
+ tab.results_table.setFont(table_font)
2279
+ # Resize rows and columns to fit new font size
2280
+ tab.results_table.resizeColumnsToContents()
2281
+ tab.results_table.resizeRowsToContents()
2282
+
2283
+ # Update status bar
2284
+ self.statusBar().showMessage(f"Zoom level adjusted to {int(current_size * factor)}", 2000)
2285
+
2286
+ except Exception as e:
2287
+ self.statusBar().showMessage(f"Error adjusting zoom: {str(e)}", 2000)
2288
+
2289
+ def reset_zoom(self):
2290
+ """Reset zoom level to default"""
2291
+ try:
2292
+ # Default font sizes
2293
+ sql_editor_size = 12
2294
+ table_size = 10
2295
+
2296
+ # Update all tabs
2297
+ for i in range(self.tab_widget.count()):
2298
+ tab = self.tab_widget.widget(i)
2299
+
2300
+ # Reset editor font
2301
+ if hasattr(tab, 'query_edit'):
2302
+ editor_font = tab.query_edit.font()
2303
+ editor_font.setPointSizeF(sql_editor_size)
2304
+ tab.query_edit.setFont(editor_font)
2305
+ tab.query_edit.update_line_number_area_width(0)
2306
+
2307
+ # Reset table font
2308
+ if hasattr(tab, 'results_table'):
2309
+ table_font = tab.results_table.font()
2310
+ table_font.setPointSizeF(table_size)
2311
+ tab.results_table.setFont(table_font)
2312
+ tab.results_table.resizeColumnsToContents()
2313
+ tab.results_table.resizeRowsToContents()
2314
+
2315
+ self.statusBar().showMessage("Zoom level reset to default", 2000)
2316
+
2317
+ except Exception as e:
2318
+ self.statusBar().showMessage(f"Error resetting zoom: {str(e)}", 2000)
2319
+
1431
2320
  def main():
1432
2321
  app = QApplication(sys.argv)
2322
+ app.setStyle(QStyleFactory.create('Fusion'))
1433
2323
 
1434
- # Set application style
1435
- app.setStyle('Fusion')
2324
+ # Set application icon
2325
+ icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icon.png")
2326
+ if os.path.exists(icon_path):
2327
+ app.setWindowIcon(QIcon(icon_path))
2328
+ else:
2329
+ # Fallback to the main logo if the icon isn't found
2330
+ main_logo_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "sqlshell_logo.png")
2331
+ if os.path.exists(main_logo_path):
2332
+ app.setWindowIcon(QIcon(main_logo_path))
1436
2333
 
1437
- # Apply custom palette for the entire application
1438
- palette = QPalette()
1439
- palette.setColor(QPalette.ColorRole.Window, QColor("#ECF0F1"))
1440
- palette.setColor(QPalette.ColorRole.WindowText, QColor("#2C3E50"))
1441
- palette.setColor(QPalette.ColorRole.Base, QColor("#FFFFFF"))
1442
- palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#F5F5F5"))
1443
- palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#2C3E50"))
1444
- palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#FFFFFF"))
1445
- palette.setColor(QPalette.ColorRole.Text, QColor("#2C3E50"))
1446
- palette.setColor(QPalette.ColorRole.Button, QColor("#3498DB"))
1447
- palette.setColor(QPalette.ColorRole.ButtonText, QColor("#FFFFFF"))
1448
- palette.setColor(QPalette.ColorRole.BrightText, QColor("#FFFFFF"))
1449
- palette.setColor(QPalette.ColorRole.Link, QColor("#3498DB"))
1450
- palette.setColor(QPalette.ColorRole.Highlight, QColor("#3498DB"))
1451
- palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF"))
1452
- app.setPalette(palette)
2334
+ # Ensure we have a valid working directory with pool.db
2335
+ package_dir = os.path.dirname(os.path.abspath(__file__))
2336
+ working_dir = os.getcwd()
1453
2337
 
1454
- # Set default font
1455
- default_font = QFont("Segoe UI", 10)
1456
- app.setFont(default_font)
1457
-
1458
- # Create and show the application
1459
- sql_shell = SQLShell()
2338
+ # If pool.db doesn't exist in current directory, copy it from package
2339
+ if not os.path.exists(os.path.join(working_dir, 'pool.db')):
2340
+ import shutil
2341
+ package_db = os.path.join(package_dir, 'pool.db')
2342
+ if os.path.exists(package_db):
2343
+ shutil.copy2(package_db, working_dir)
2344
+ else:
2345
+ package_db = os.path.join(os.path.dirname(package_dir), 'pool.db')
2346
+ if os.path.exists(package_db):
2347
+ shutil.copy2(package_db, working_dir)
1460
2348
 
1461
- # Set application icon (if available)
1462
2349
  try:
1463
- app_icon = QIcon("sqlshell/resources/icon.png")
1464
- sql_shell.setWindowIcon(app_icon)
1465
- except:
1466
- # If icon not found, continue without it
1467
- pass
1468
-
1469
- sql_shell.show()
1470
- sys.exit(app.exec())
2350
+ # Show splash screen
2351
+ splash = AnimatedSplashScreen()
2352
+ splash.show()
2353
+
2354
+ # Process events immediately to ensure the splash screen appears
2355
+ app.processEvents()
2356
+
2357
+ # Create main window but don't show it yet
2358
+ print("Initializing main application...")
2359
+ window = SQLShell()
2360
+
2361
+ # Define the function to show main window and hide splash
2362
+ def show_main_window():
2363
+ # Properly finish the splash screen
2364
+ if splash:
2365
+ splash.finish(window)
2366
+
2367
+ # Show the main window
2368
+ window.show()
2369
+ timer.stop()
2370
+
2371
+ # Also stop the failsafe timer if it's still running
2372
+ if failsafe_timer.isActive():
2373
+ failsafe_timer.stop()
2374
+
2375
+ print("Main application started")
2376
+
2377
+ # Create a failsafe timer in case the splash screen fails to show
2378
+ def failsafe_show_window():
2379
+ if not window.isVisible():
2380
+ print("Failsafe timer activated - showing main window")
2381
+ if splash:
2382
+ try:
2383
+ # First try to use the proper finish method
2384
+ splash.finish(window)
2385
+ except Exception as e:
2386
+ print(f"Error in failsafe finish: {e}")
2387
+ try:
2388
+ # Fall back to direct close if finish fails
2389
+ splash.close()
2390
+ except Exception:
2391
+ pass
2392
+ window.show()
2393
+
2394
+ # Create and show main window after delay
2395
+ timer = QTimer()
2396
+ timer.setSingleShot(True) # Ensure it only fires once
2397
+ timer.timeout.connect(show_main_window)
2398
+ timer.start(2000) # 2 second delay
2399
+
2400
+ # Failsafe timer - show the main window after 5 seconds even if splash screen fails
2401
+ failsafe_timer = QTimer()
2402
+ failsafe_timer.setSingleShot(True)
2403
+ failsafe_timer.timeout.connect(failsafe_show_window)
2404
+ failsafe_timer.start(5000) # 5 second delay
2405
+
2406
+ sys.exit(app.exec())
2407
+
2408
+ except Exception as e:
2409
+ print(f"Error during startup: {e}")
2410
+ # If there's any error with the splash screen, just show the main window directly
2411
+ window = SQLShell()
2412
+ window.show()
2413
+ sys.exit(app.exec())
1471
2414
 
1472
2415
  if __name__ == '__main__':
1473
2416
  main()