sqlshell 0.1.8__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
@@ -7,8 +7,6 @@ if __name__ == "__main__":
7
7
  project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8
8
  sys.path.insert(0, project_root)
9
9
 
10
- import duckdb
11
- import sqlite3
12
10
  import pandas as pd
13
11
  from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
14
12
  QHBoxLayout, QTextEdit, QPushButton, QFileDialog,
@@ -16,10 +14,10 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
16
14
  QTableWidgetItem, QHeaderView, QMessageBox, QPlainTextEdit,
17
15
  QCompleter, QFrame, QToolButton, QSizePolicy, QTabWidget,
18
16
  QStyleFactory, QToolBar, QStatusBar, QLineEdit, QMenu,
19
- QCheckBox, QWidgetAction, QMenuBar, QInputDialog,
20
- QStyledItemDelegate)
21
- from PyQt6.QtCore import Qt, QAbstractTableModel, QRegularExpression, QRect, QSize, QStringListModel, QPropertyAnimation, QEasingCurve, QTimer, QPoint
22
- from PyQt6.QtGui import QFont, QColor, QSyntaxHighlighter, QTextCharFormat, QPainter, QTextFormat, QTextCursor, QIcon, QPalette, QLinearGradient, QBrush, QPixmap, QPolygon, QPainterPath
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
23
21
  import numpy as np
24
22
  from datetime import datetime
25
23
 
@@ -27,459 +25,128 @@ from sqlshell import create_test_data
27
25
  from sqlshell.splash_screen import AnimatedSplashScreen
28
26
  from sqlshell.syntax_highlighter import SQLSyntaxHighlighter
29
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
30
31
 
31
- class BarChartDelegate(QStyledItemDelegate):
32
+ class DraggableTablesList(QListWidget):
33
+ """Custom QListWidget that provides better drag functionality for table names."""
34
+
32
35
  def __init__(self, parent=None):
33
36
  super().__init__(parent)
34
- self.min_val = 0
35
- self.max_val = 1
36
- self.bar_color = QColor("#3498DB")
37
-
38
- def set_range(self, min_val, max_val):
39
- self.min_val = min_val
40
- self.max_val = max_val
41
-
42
- def paint(self, painter, option, index):
43
- # Draw the default background
44
- super().paint(painter, option, index)
37
+ self.parent = parent
38
+ self.setDragEnabled(True)
39
+ self.setDragDropMode(QListWidget.DragDropMode.DragOnly)
45
40
 
46
- try:
47
- text = index.data()
48
- value = float(text.replace(',', ''))
49
-
50
- # Calculate normalized value
51
- range_val = self.max_val - self.min_val if self.max_val != self.min_val else 1
52
- normalized = (value - self.min_val) / range_val
53
-
54
- # Define bar dimensions
55
- bar_height = 16
56
- max_bar_width = 100
57
- bar_width = max(5, int(max_bar_width * normalized))
58
-
59
- # Calculate positions
60
- text_width = option.fontMetrics.horizontalAdvance(text) + 10
61
- bar_x = option.rect.left() + text_width + 10
62
- bar_y = option.rect.center().y() - bar_height // 2
63
-
64
- # Draw the bar
65
- bar_rect = QRect(bar_x, bar_y, bar_width, bar_height)
66
- painter.fillRect(bar_rect, self.bar_color)
67
-
68
- # Draw the text
69
- text_rect = QRect(option.rect.left() + 4, option.rect.top(),
70
- text_width, option.rect.height())
71
- painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text)
72
-
73
- except (ValueError, AttributeError):
74
- # If not a number, just draw the text
75
- super().paint(painter, option, index)
76
-
77
- class FilterHeader(QHeaderView):
78
- def __init__(self, parent=None):
79
- super().__init__(Qt.Orientation.Horizontal, parent)
80
- self.filter_buttons = []
81
- self.active_filters = {} # Track active filters for each column
82
- self.columns_with_bars = set() # Track which columns show bar charts
83
- self.bar_delegates = {} # Store delegates for columns with bars
84
- self.setSectionsClickable(True)
85
- self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
86
- self.customContextMenuRequested.connect(self.show_header_context_menu)
87
- self.main_window = None # Store reference to main window
88
- self.filter_icon_color = QColor("#3498DB") # Bright blue color for filter icon
89
-
90
- def toggle_bar_chart(self, column_index):
91
- """Toggle bar chart visualization for a column"""
92
- table = self.parent()
93
- if not table:
94
- return
95
-
96
- if column_index in self.columns_with_bars:
97
- # Remove bars
98
- self.columns_with_bars.remove(column_index)
99
- if column_index in self.bar_delegates:
100
- table.setItemDelegateForColumn(column_index, None)
101
- del self.bar_delegates[column_index]
102
- else:
103
- # Add bars
104
- self.columns_with_bars.add(column_index)
105
-
106
- # Get all values for normalization
107
- values = []
108
- for row in range(table.rowCount()):
109
- item = table.item(row, column_index)
110
- if item:
111
- try:
112
- value = float(item.text().replace(',', ''))
113
- values.append(value)
114
- except ValueError:
115
- continue
116
-
117
- if not values:
118
- return
119
-
120
- # Calculate min and max for normalization
121
- min_val = min(values)
122
- max_val = max(values)
123
-
124
- # Create and set up delegate
125
- delegate = BarChartDelegate(table)
126
- delegate.set_range(min_val, max_val)
127
- self.bar_delegates[column_index] = delegate
128
- table.setItemDelegateForColumn(column_index, delegate)
129
-
130
- # Update the view
131
- table.viewport().update()
132
-
133
- def show_header_context_menu(self, pos):
134
- """Show context menu for header section"""
135
- logical_index = self.logicalIndexAt(pos)
136
- if logical_index < 0:
137
- return
138
-
139
- # Create context menu
140
- context_menu = QMenu(self)
141
- context_menu.setStyleSheet("""
142
- QMenu {
143
- background-color: white;
144
- border: 1px solid #BDC3C7;
145
- padding: 5px;
146
- }
147
- QMenu::item {
148
- padding: 5px 20px;
149
- }
150
- QMenu::item:selected {
151
- background-color: #3498DB;
152
- color: white;
153
- }
154
- """)
155
-
156
- # Add sort actions
157
- sort_asc_action = context_menu.addAction("Sort Ascending")
158
- sort_desc_action = context_menu.addAction("Sort Descending")
159
- context_menu.addSeparator()
160
- filter_action = context_menu.addAction("Filter...")
161
-
162
- # Add bar chart action if column is numeric
163
- table = self.parent()
164
- if table and table.rowCount() > 0:
165
- try:
166
- # Check if column contains numeric values
167
- sample_value = table.item(0, logical_index).text()
168
- float(sample_value.replace(',', '')) # Try converting to float
169
-
170
- context_menu.addSeparator()
171
- toggle_bar_action = context_menu.addAction(
172
- "Remove Bar Chart" if logical_index in self.columns_with_bars
173
- else "Add Bar Chart"
174
- )
175
- except (ValueError, AttributeError):
176
- toggle_bar_action = None
177
- else:
178
- toggle_bar_action = None
179
-
180
- # Show menu and get selected action
181
- action = context_menu.exec(self.mapToGlobal(pos))
182
-
183
- if not action:
184
- return
185
-
186
- table = self.parent()
187
- if not table:
188
- return
189
-
190
- if action == sort_asc_action:
191
- table.sortItems(logical_index, Qt.SortOrder.AscendingOrder)
192
- elif action == sort_desc_action:
193
- table.sortItems(logical_index, Qt.SortOrder.DescendingOrder)
194
- elif action == filter_action:
195
- self.show_filter_menu(logical_index)
196
- elif action == toggle_bar_action:
197
- self.toggle_bar_chart(logical_index)
198
-
199
- def set_main_window(self, window):
200
- """Set the reference to the main window"""
201
- self.main_window = window
202
-
203
- def paintSection(self, painter, rect, logical_index):
204
- """Override paint section to add filter indicator"""
205
- super().paintSection(painter, rect, logical_index)
206
-
207
- if logical_index in self.active_filters:
208
- # Draw background highlight for filtered columns
209
- highlight_color = QColor(52, 152, 219, 30) # Light blue background
210
- painter.fillRect(rect, highlight_color)
211
-
212
- # Make icon larger and more visible
213
- icon_size = min(rect.height() - 8, 24) # Larger icon, but not too large
214
- margin = 6
215
- icon_rect = QRect(
216
- rect.right() - icon_size - margin,
217
- rect.top() + (rect.height() - icon_size) // 2,
218
- icon_size,
219
- icon_size
220
- )
221
-
222
- # Draw filter icon with improved visibility
223
- painter.save()
224
-
225
- # Set up the pen for better visibility
226
- pen = painter.pen()
227
- pen.setWidth(3) # Thicker lines
228
- pen.setColor(self.filter_icon_color)
229
- painter.setPen(pen)
230
-
231
- # Calculate points for larger funnel shape
232
- points = [
233
- QPoint(icon_rect.left(), icon_rect.top()),
234
- QPoint(icon_rect.right(), icon_rect.top()),
235
- QPoint(icon_rect.center().x() + icon_size//3, icon_rect.center().y()),
236
- QPoint(icon_rect.center().x() + icon_size//3, icon_rect.bottom()),
237
- QPoint(icon_rect.center().x() - icon_size//3, icon_rect.bottom()),
238
- QPoint(icon_rect.center().x() - icon_size//3, icon_rect.center().y()),
239
- QPoint(icon_rect.left(), icon_rect.top())
240
- ]
241
-
242
- # Create and fill path
243
- path = QPainterPath()
244
- path.moveTo(float(points[0].x()), float(points[0].y()))
245
- for point in points[1:]:
246
- path.lineTo(float(point.x()), float(point.y()))
247
-
248
- # Fill with semi-transparent blue
249
- painter.fillPath(path, QBrush(QColor(52, 152, 219, 120))) # More opaque fill
250
-
251
- # Draw outline
252
- painter.drawPolyline(QPolygon(points))
253
-
254
- # If multiple values are filtered, add a number
255
- if len(self.active_filters[logical_index]) > 1:
256
- # Draw number with better visibility
257
- number_rect = QRect(icon_rect.left(), icon_rect.top(),
258
- icon_rect.width(), icon_rect.height())
259
- painter.setFont(QFont("Arial", icon_size//2, QFont.Weight.Bold))
260
-
261
- # Draw text shadow for better contrast
262
- painter.setPen(QColor("white"))
263
- painter.drawText(number_rect.adjusted(1, 1, 1, 1),
264
- Qt.AlignmentFlag.AlignCenter,
265
- str(len(self.active_filters[logical_index])))
266
-
267
- # Draw main text
268
- painter.setPen(self.filter_icon_color)
269
- painter.drawText(number_rect, Qt.AlignmentFlag.AlignCenter,
270
- str(len(self.active_filters[logical_index])))
271
-
272
- painter.restore()
273
-
274
- # Draw a more visible indicator at the bottom of the header section
275
- painter.save()
276
- indicator_height = 3 # Thicker indicator line
277
- indicator_rect = QRect(rect.left(), rect.bottom() - indicator_height,
278
- rect.width(), indicator_height)
279
- painter.fillRect(indicator_rect, self.filter_icon_color)
280
- painter.restore()
281
-
282
- def show_filter_menu(self, logical_index):
283
- if not self.parent() or not isinstance(self.parent(), QTableWidget):
284
- return
285
-
286
- table = self.parent()
287
- unique_values = set()
288
-
289
- # Collect unique values from the column
290
- for row in range(table.rowCount()):
291
- item = table.item(row, logical_index)
292
- if item and not table.isRowHidden(row):
293
- unique_values.add(item.text())
294
-
295
- # Create and show the filter menu
296
- menu = QMenu(self)
297
- menu.setStyleSheet("""
298
- QMenu {
299
- background-color: white;
300
- border: 1px solid #BDC3C7;
301
- padding: 5px;
302
- }
303
- QMenu::item {
304
- padding: 5px 20px;
305
- }
306
- QMenu::item:selected {
307
- background-color: #3498DB;
308
- color: white;
309
- }
310
- QCheckBox {
311
- padding: 5px;
312
- }
313
- QScrollArea {
41
+ # Apply custom styling
42
+ self.setStyleSheet("""
43
+ QListWidget {
44
+ background-color: rgba(255, 255, 255, 0.1);
314
45
  border: none;
315
- }
316
- """)
317
-
318
- # Add search box at the top
319
- search_widget = QWidget(menu)
320
- search_layout = QVBoxLayout(search_widget)
321
- search_edit = QLineEdit(search_widget)
322
- search_edit.setPlaceholderText("Search values...")
323
- search_layout.addWidget(search_edit)
324
-
325
- # Add action for search widget
326
- search_action = QWidgetAction(menu)
327
- search_action.setDefaultWidget(search_widget)
328
- menu.addAction(search_action)
329
- menu.addSeparator()
330
-
331
- # Add "Select All" checkbox
332
- select_all = QCheckBox("Select All", menu)
333
- select_all.setChecked(True)
334
- select_all_action = QWidgetAction(menu)
335
- select_all_action.setDefaultWidget(select_all)
336
- menu.addAction(select_all_action)
337
- menu.addSeparator()
338
-
339
- # Create scrollable area for checkboxes
340
- scroll_widget = QWidget(menu)
341
- scroll_layout = QVBoxLayout(scroll_widget)
342
- scroll_layout.setSpacing(2)
343
- scroll_layout.setContentsMargins(0, 0, 0, 0)
344
-
345
- # Add checkboxes for unique values
346
- value_checkboxes = {}
347
- for value in sorted(unique_values):
348
- checkbox = QCheckBox(str(value), scroll_widget)
349
- # Set checked state based on active filters
350
- checkbox.setChecked(logical_index not in self.active_filters or
351
- value in self.active_filters[logical_index])
352
- value_checkboxes[value] = checkbox
353
- scroll_layout.addWidget(checkbox)
354
-
355
- # Add scrollable area to menu
356
- scroll_action = QWidgetAction(menu)
357
- scroll_action.setDefaultWidget(scroll_widget)
358
- menu.addAction(scroll_action)
359
-
360
- # Connect search box to filter checkboxes
361
- def filter_checkboxes(text):
362
- for value, checkbox in value_checkboxes.items():
363
- checkbox.setVisible(text.lower() in str(value).lower())
364
-
365
- search_edit.textChanged.connect(filter_checkboxes)
366
-
367
- # Connect select all to other checkboxes
368
- def toggle_all(state):
369
- for checkbox in value_checkboxes.values():
370
- if not checkbox.isHidden(): # Only toggle visible checkboxes
371
- checkbox.setChecked(state)
372
-
373
- select_all.stateChanged.connect(toggle_all)
374
-
375
- # Add Apply and Clear buttons
376
- menu.addSeparator()
377
- apply_button = QPushButton("Apply Filter", menu)
378
- apply_button.setStyleSheet("""
379
- QPushButton {
380
- background-color: #2ECC71;
46
+ border-radius: 4px;
381
47
  color: white;
382
- border: none;
383
- padding: 5px 15px;
384
- border-radius: 3px;
385
48
  }
386
- QPushButton:hover {
387
- background-color: #27AE60;
388
- }
389
- """)
390
-
391
- clear_button = QPushButton("Clear Filter", menu)
392
- clear_button.setStyleSheet("""
393
- QPushButton {
394
- background-color: #E74C3C;
395
- color: white;
396
- border: none;
397
- padding: 5px 15px;
398
- border-radius: 3px;
49
+ QListWidget::item:selected {
50
+ background-color: rgba(255, 255, 255, 0.2);
399
51
  }
400
- QPushButton:hover {
401
- background-color: #C0392B;
52
+ QListWidget::item:hover:!selected {
53
+ background-color: rgba(255, 255, 255, 0.1);
402
54
  }
403
55
  """)
404
56
 
405
- button_widget = QWidget(menu)
406
- button_layout = QHBoxLayout(button_widget)
407
- button_layout.addWidget(apply_button)
408
- button_layout.addWidget(clear_button)
409
-
410
- button_action = QWidgetAction(menu)
411
- button_action.setDefaultWidget(button_widget)
412
- menu.addAction(button_action)
413
-
414
- def apply_filter():
415
- # Get selected values
416
- selected_values = {value for value, checkbox in value_checkboxes.items()
417
- if checkbox.isChecked()}
57
+ def startDrag(self, supportedActions):
58
+ """Override startDrag to customize the drag data."""
59
+ item = self.currentItem()
60
+ if not item:
61
+ return
418
62
 
419
- if len(selected_values) < len(unique_values):
420
- # Store active filter only if not all values are selected
421
- self.active_filters[logical_index] = selected_values
422
- else:
423
- # Remove filter if all values are selected
424
- self.active_filters.pop(logical_index, None)
425
-
426
- # Apply all active filters
427
- self.apply_all_filters(table)
428
-
429
- menu.close()
430
- self.updateSection(logical_index) # Redraw section to show/hide filter icon
431
-
432
- def clear_filter():
433
- # Remove filter for this column
434
- if logical_index in self.active_filters:
435
- del self.active_filters[logical_index]
436
-
437
- # Apply remaining filters
438
- self.apply_all_filters(table)
439
-
440
- menu.close()
441
- self.updateSection(logical_index) # Redraw section to hide filter icon
442
-
443
- apply_button.clicked.connect(apply_filter)
444
- clear_button.clicked.connect(clear_filter)
445
-
446
- # Show menu under the header section
447
- header_pos = self.mapToGlobal(self.geometry().bottomLeft())
448
- header_pos.setX(header_pos.x() + self.sectionPosition(logical_index))
449
- menu.exec(header_pos)
450
-
451
- def apply_all_filters(self, table):
452
- """Apply all active filters to the table"""
453
- # Show all rows first
454
- for row in range(table.rowCount()):
455
- table.setRowHidden(row, False)
456
-
457
- # Apply each active filter
458
- for col_idx, allowed_values in self.active_filters.items():
459
- for row in range(table.rowCount()):
460
- item = table.item(row, col_idx)
461
- if item and not table.isRowHidden(row):
462
- table.setRowHidden(row, item.text() not in allowed_values)
463
-
464
- # Update status bar with visible row count
465
- if self.main_window:
466
- visible_rows = sum(1 for row in range(table.rowCount())
467
- if not table.isRowHidden(row))
468
- total_filters = len(self.active_filters)
469
- filter_text = f" ({total_filters} filter{'s' if total_filters != 1 else ''} active)" if total_filters > 0 else ""
470
- self.main_window.statusBar().showMessage(
471
- f"Showing {visible_rows:,} rows{filter_text}")
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))
472
136
 
473
137
  class SQLShell(QMainWindow):
474
138
  def __init__(self):
475
139
  super().__init__()
476
- self.conn = None
477
- self.current_connection_type = None
478
- self.loaded_tables = {} # Keep track of loaded tables
479
- self.table_columns = {} # Keep track of table columns
140
+ self.db_manager = DatabaseManager()
480
141
  self.current_df = None # Store the current DataFrame for filtering
481
142
  self.filter_widgets = [] # Store filter line edits
482
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()
483
150
 
484
151
  # Define color scheme
485
152
  self.colors = {
@@ -499,6 +166,9 @@ class SQLShell(QMainWindow):
499
166
 
500
167
  self.init_ui()
501
168
  self.apply_stylesheet()
169
+
170
+ # Create initial tab
171
+ self.add_tab()
502
172
 
503
173
  def apply_stylesheet(self):
504
174
  """Apply custom stylesheet to the application"""
@@ -643,6 +313,44 @@ class SQLShell(QMainWindow):
643
313
  padding: 8px;
644
314
  }}
645
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
+
646
354
  QPlainTextEdit, QTextEdit {{
647
355
  background-color: white;
648
356
  border-radius: 4px;
@@ -657,7 +365,39 @@ class SQLShell(QMainWindow):
657
365
 
658
366
  def init_ui(self):
659
367
  self.setWindowTitle('SQL Shell')
660
- 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))
661
401
 
662
402
  # Create menu bar
663
403
  menubar = self.menuBar()
@@ -672,6 +412,10 @@ class SQLShell(QMainWindow):
672
412
  open_project_action.setShortcut('Ctrl+O')
673
413
  open_project_action.triggered.connect(self.open_project)
674
414
 
415
+ # Add Recent Projects submenu
416
+ self.recent_projects_menu = file_menu.addMenu('Recent Projects')
417
+ self.update_recent_projects_menu()
418
+
675
419
  save_project_action = file_menu.addAction('Save Project')
676
420
  save_project_action.setShortcut('Ctrl+S')
677
421
  save_project_action.triggered.connect(self.save_project)
@@ -686,6 +430,48 @@ class SQLShell(QMainWindow):
686
430
  exit_action.setShortcut('Ctrl+Q')
687
431
  exit_action.triggered.connect(self.close)
688
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)
474
+
689
475
  # Create custom status bar
690
476
  status_bar = QStatusBar()
691
477
  self.setStatusBar(status_bar)
@@ -755,21 +541,7 @@ class SQLShell(QMainWindow):
755
541
  left_layout.addLayout(table_actions_layout)
756
542
 
757
543
  # Tables list with custom styling
758
- self.tables_list = QListWidget()
759
- self.tables_list.setStyleSheet("""
760
- QListWidget {
761
- background-color: rgba(255, 255, 255, 0.1);
762
- border: none;
763
- border-radius: 4px;
764
- color: white;
765
- }
766
- QListWidget::item:selected {
767
- background-color: rgba(255, 255, 255, 0.2);
768
- }
769
- QListWidget::item:hover:!selected {
770
- background-color: rgba(255, 255, 255, 0.1);
771
- }
772
- """)
544
+ self.tables_list = DraggableTablesList()
773
545
  self.tables_list.itemClicked.connect(self.show_table_preview)
774
546
  self.tables_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
775
547
  self.tables_list.customContextMenuRequested.connect(self.show_tables_context_menu)
@@ -778,7 +550,7 @@ class SQLShell(QMainWindow):
778
550
  # Add spacer at the bottom
779
551
  left_layout.addStretch()
780
552
 
781
- # Right panel for query and results
553
+ # Right panel for query tabs and results
782
554
  right_panel = QFrame()
783
555
  right_panel.setObjectName("content_panel")
784
556
  right_layout = QVBoxLayout(right_panel)
@@ -790,143 +562,82 @@ class SQLShell(QMainWindow):
790
562
  query_header.setObjectName("header_label")
791
563
  right_layout.addWidget(query_header)
792
564
 
793
- # Create splitter for query and results
794
- splitter = QSplitter(Qt.Orientation.Vertical)
795
- splitter.setHandleWidth(8)
796
- splitter.setChildrenCollapsible(False)
797
-
798
- # Top part - Query section
799
- query_widget = QFrame()
800
- query_widget.setObjectName("content_panel")
801
- query_layout = QVBoxLayout(query_widget)
802
- query_layout.setContentsMargins(16, 16, 16, 16)
803
- query_layout.setSpacing(12)
804
-
805
- # Query input
806
- self.query_edit = SQLEditor()
807
- # Apply syntax highlighting to the query editor
808
- self.sql_highlighter = SQLSyntaxHighlighter(self.query_edit.document())
809
- query_layout.addWidget(self.query_edit)
810
-
811
- # Button row
812
- button_layout = QHBoxLayout()
813
- button_layout.setSpacing(8)
814
-
815
- self.execute_btn = QPushButton('Execute Query')
816
- self.execute_btn.setObjectName("primary_button")
817
- self.execute_btn.setIcon(QIcon.fromTheme("media-playback-start"))
818
- self.execute_btn.clicked.connect(self.execute_query)
819
- self.execute_btn.setToolTip("Execute Query (Ctrl+Enter)")
820
-
821
- self.clear_btn = QPushButton('Clear')
822
- self.clear_btn.setIcon(QIcon.fromTheme("edit-clear"))
823
- self.clear_btn.clicked.connect(self.clear_query)
824
-
825
- button_layout.addWidget(self.execute_btn)
826
- button_layout.addWidget(self.clear_btn)
827
- button_layout.addStretch()
828
-
829
- query_layout.addLayout(button_layout)
830
-
831
- # Bottom part - Results section
832
- results_widget = QFrame()
833
- results_widget.setObjectName("content_panel")
834
- results_layout = QVBoxLayout(results_widget)
835
- results_layout.setContentsMargins(16, 16, 16, 16)
836
- results_layout.setSpacing(12)
837
-
838
- # Results header with row count and export options
839
- results_header_layout = QHBoxLayout()
840
-
841
- results_title = QLabel("RESULTS")
842
- results_title.setObjectName("header_label")
843
-
844
- self.row_count_label = QLabel("")
845
- self.row_count_label.setStyleSheet(f"color: {self.colors['text_light']}; font-style: italic;")
846
-
847
- results_header_layout.addWidget(results_title)
848
- results_header_layout.addWidget(self.row_count_label)
849
- results_header_layout.addStretch()
850
-
851
- # Export buttons
852
- export_layout = QHBoxLayout()
853
- export_layout.setSpacing(8)
854
-
855
- self.export_excel_btn = QPushButton('Export to Excel')
856
- self.export_excel_btn.setIcon(QIcon.fromTheme("x-office-spreadsheet"))
857
- self.export_excel_btn.clicked.connect(self.export_to_excel)
858
-
859
- self.export_parquet_btn = QPushButton('Export to Parquet')
860
- self.export_parquet_btn.setIcon(QIcon.fromTheme("document-save"))
861
- self.export_parquet_btn.clicked.connect(self.export_to_parquet)
862
-
863
- export_layout.addWidget(self.export_excel_btn)
864
- export_layout.addWidget(self.export_parquet_btn)
865
-
866
- results_header_layout.addLayout(export_layout)
867
- results_layout.addLayout(results_header_layout)
868
-
869
- # Table widget for results with modern styling
870
- self.results_table = QTableWidget()
871
- self.results_table.setSortingEnabled(True)
872
- self.results_table.setAlternatingRowColors(True)
873
-
874
- # Set custom header for filtering
875
- header = FilterHeader(self.results_table)
876
- header.set_main_window(self) # Set reference to main window
877
- self.results_table.setHorizontalHeader(header)
878
- header.setStretchLastSection(True)
879
- header.setSectionsMovable(True)
880
-
881
- self.results_table.verticalHeader().setVisible(False)
882
- self.results_table.setShowGrid(True)
883
- self.results_table.setGridStyle(Qt.PenStyle.SolidLine)
884
- self.results_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
885
-
886
- results_layout.addWidget(self.results_table)
887
-
888
- # Add widgets to splitter
889
- splitter.addWidget(query_widget)
890
- 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)
891
573
 
892
- # Set initial sizes for splitter
893
- splitter.setSizes([300, 500])
574
+ # Add a "+" button to the tab bar
575
+ self.tab_widget.setCornerWidget(self.create_tab_corner_widget())
894
576
 
895
- right_layout.addWidget(splitter)
577
+ right_layout.addWidget(self.tab_widget)
896
578
 
897
579
  # Add panels to main layout
898
580
  main_layout.addWidget(left_panel, 1)
899
581
  main_layout.addWidget(right_panel, 4)
900
582
 
901
583
  # Status bar
902
- self.statusBar().showMessage('Ready | Ctrl+Enter: Execute Query | Ctrl+K: Toggle Comment')
903
-
904
- # Show keyboard shortcuts in a tooltip for the query editor
905
- self.query_edit.setToolTip(
906
- "Keyboard Shortcuts:\n"
907
- "Ctrl+Enter: Execute Query\n"
908
- "Ctrl+K: Toggle Comment\n"
909
- "Tab: Insert 4 spaces\n"
910
- "Ctrl+Space: Show autocomplete"
911
- )
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
912
617
 
913
618
  def populate_table(self, df):
914
619
  """Populate the results table with DataFrame data using memory-efficient chunking"""
915
620
  try:
621
+ # Get the current tab
622
+ current_tab = self.get_current_tab()
623
+ if not current_tab:
624
+ return
625
+
916
626
  # Store the current DataFrame for filtering
917
- self.current_df = df.copy()
627
+ current_tab.current_df = df.copy()
628
+ self.current_df = df.copy() # Keep this for compatibility with existing code
918
629
 
919
630
  # Remember which columns had bar charts
920
- header = self.results_table.horizontalHeader()
631
+ header = current_tab.results_table.horizontalHeader()
921
632
  if isinstance(header, FilterHeader):
922
633
  columns_with_bars = header.columns_with_bars.copy()
923
634
  else:
924
635
  columns_with_bars = set()
925
636
 
926
637
  # Clear existing data
927
- self.results_table.clearContents()
928
- self.results_table.setRowCount(0)
929
- self.results_table.setColumnCount(0)
638
+ current_tab.results_table.clearContents()
639
+ current_tab.results_table.setRowCount(0)
640
+ current_tab.results_table.setColumnCount(0)
930
641
 
931
642
  if df.empty:
932
643
  self.statusBar().showMessage("Query returned no results")
@@ -935,11 +646,11 @@ class SQLShell(QMainWindow):
935
646
  # Set up the table dimensions
936
647
  row_count = len(df)
937
648
  col_count = len(df.columns)
938
- self.results_table.setColumnCount(col_count)
649
+ current_tab.results_table.setColumnCount(col_count)
939
650
 
940
651
  # Set column headers
941
652
  headers = [str(col) for col in df.columns]
942
- self.results_table.setHorizontalHeaderLabels(headers)
653
+ current_tab.results_table.setHorizontalHeaderLabels(headers)
943
654
 
944
655
  # Calculate chunk size (adjust based on available memory)
945
656
  CHUNK_SIZE = 1000
@@ -950,27 +661,30 @@ class SQLShell(QMainWindow):
950
661
  chunk = df.iloc[chunk_start:chunk_end]
951
662
 
952
663
  # Add rows for this chunk
953
- self.results_table.setRowCount(chunk_end)
664
+ current_tab.results_table.setRowCount(chunk_end)
954
665
 
955
666
  for row_idx, (_, row_data) in enumerate(chunk.iterrows(), start=chunk_start):
956
667
  for col_idx, value in enumerate(row_data):
957
668
  formatted_value = self.format_value(value)
958
669
  item = QTableWidgetItem(formatted_value)
959
- self.results_table.setItem(row_idx, col_idx, item)
670
+ current_tab.results_table.setItem(row_idx, col_idx, item)
960
671
 
961
672
  # Process events to keep UI responsive
962
673
  QApplication.processEvents()
963
674
 
964
675
  # Optimize column widths
965
- self.results_table.resizeColumnsToContents()
676
+ current_tab.results_table.resizeColumnsToContents()
966
677
 
967
678
  # Restore bar charts for columns that previously had them
968
- header = self.results_table.horizontalHeader()
679
+ header = current_tab.results_table.horizontalHeader()
969
680
  if isinstance(header, FilterHeader):
970
681
  for col_idx in columns_with_bars:
971
682
  if col_idx < col_count: # Only if column still exists
972
683
  header.toggle_bar_chart(col_idx)
973
684
 
685
+ # Update row count label
686
+ current_tab.row_count_label.setText(f"{row_count:,} rows")
687
+
974
688
  # Update status
975
689
  memory_usage = df.memory_usage(deep=True).sum() / (1024 * 1024) # Convert to MB
976
690
  self.statusBar().showMessage(
@@ -1041,11 +755,10 @@ class SQLShell(QMainWindow):
1041
755
  return str(value)
1042
756
 
1043
757
  def browse_files(self):
1044
- if not self.conn:
758
+ if not self.db_manager.is_connected():
1045
759
  # Create a default in-memory DuckDB connection if none exists
1046
- self.conn = duckdb.connect(':memory:')
1047
- self.current_connection_type = 'duckdb'
1048
- 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)
1049
762
 
1050
763
  file_names, _ = QFileDialog.getOpenFileNames(
1051
764
  self,
@@ -1056,38 +769,8 @@ class SQLShell(QMainWindow):
1056
769
 
1057
770
  for file_name in file_names:
1058
771
  try:
1059
- if file_name.endswith(('.xlsx', '.xls')):
1060
- df = pd.read_excel(file_name)
1061
- elif file_name.endswith('.csv'):
1062
- df = pd.read_csv(file_name)
1063
- elif file_name.endswith('.parquet'):
1064
- df = pd.read_parquet(file_name)
1065
- else:
1066
- raise ValueError("Unsupported file format")
1067
-
1068
- # Generate table name from file name
1069
- base_name = os.path.splitext(os.path.basename(file_name))[0]
1070
- table_name = self.sanitize_table_name(base_name)
1071
-
1072
- # Ensure unique table name
1073
- original_name = table_name
1074
- counter = 1
1075
- while table_name in self.loaded_tables:
1076
- table_name = f"{original_name}_{counter}"
1077
- counter += 1
1078
-
1079
- # Handle table creation based on database type
1080
- if self.current_connection_type == 'sqlite':
1081
- # For SQLite, create a table from the DataFrame
1082
- df.to_sql(table_name, self.conn, index=False, if_exists='replace')
1083
- else:
1084
- # For DuckDB, register the DataFrame as a view
1085
- self.conn.register(table_name, df)
1086
-
1087
- self.loaded_tables[table_name] = file_name
1088
-
1089
- # Store column names
1090
- 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)
1091
774
 
1092
775
  # Update UI
1093
776
  self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
@@ -1113,26 +796,11 @@ class SQLShell(QMainWindow):
1113
796
  self.results_table.setColumnCount(0)
1114
797
  self.row_count_label.setText("")
1115
798
 
1116
- def sanitize_table_name(self, name):
1117
- # Replace invalid characters with underscores
1118
- import re
1119
- name = re.sub(r'[^a-zA-Z0-9_]', '_', name)
1120
- # Ensure it starts with a letter
1121
- if not name[0].isalpha():
1122
- name = 'table_' + name
1123
- return name.lower()
1124
-
1125
799
  def remove_selected_table(self):
1126
800
  current_item = self.tables_list.currentItem()
1127
801
  if current_item:
1128
802
  table_name = current_item.text().split(' (')[0]
1129
- if table_name in self.loaded_tables:
1130
- # Remove from DuckDB
1131
- self.conn.execute(f'DROP VIEW IF EXISTS {table_name}')
1132
- # Remove from our tracking
1133
- del self.loaded_tables[table_name]
1134
- if table_name in self.table_columns:
1135
- del self.table_columns[table_name]
803
+ if self.db_manager.remove_table(table_name):
1136
804
  # Remove from list widget
1137
805
  self.tables_list.takeItem(self.tables_list.row(current_item))
1138
806
  self.statusBar().showMessage(f'Removed table "{table_name}"')
@@ -1146,15 +814,6 @@ class SQLShell(QMainWindow):
1146
814
  def open_database(self):
1147
815
  """Open a database connection with proper error handling and resource management"""
1148
816
  try:
1149
- if self.conn:
1150
- # Close existing connection before opening new one
1151
- if self.current_connection_type == "duckdb":
1152
- self.conn.close()
1153
- else: # sqlite
1154
- self.conn.close()
1155
- self.conn = None
1156
- self.current_connection_type = None
1157
-
1158
817
  filename, _ = QFileDialog.getOpenFileName(
1159
818
  self,
1160
819
  "Open Database",
@@ -1163,86 +822,214 @@ class SQLShell(QMainWindow):
1163
822
  )
1164
823
 
1165
824
  if filename:
1166
- if self.is_sqlite_db(filename):
1167
- self.conn = sqlite3.connect(filename)
1168
- self.current_connection_type = "sqlite"
1169
- else:
1170
- self.conn = duckdb.connect(filename)
1171
- self.current_connection_type = "duckdb"
1172
-
1173
- self.load_database_tables()
1174
- self.statusBar().showMessage(f"Connected to database: {filename}")
1175
-
1176
- except (sqlite3.Error, duckdb.Error) as e:
1177
- QMessageBox.critical(self, "Database Connection Error",
1178
- f"Failed to open database:\n\n{str(e)}")
1179
- self.statusBar().showMessage("Failed to open database")
1180
- self.conn = None
1181
- self.current_connection_type = None
1182
-
1183
- def is_sqlite_db(self, filename):
1184
- """Check if the file is a SQLite database"""
1185
- try:
1186
- with open(filename, 'rb') as f:
1187
- header = f.read(16)
1188
- return header[:16] == b'SQLite format 3\x00'
1189
- except:
1190
- return False
1191
-
1192
- def load_database_tables(self):
1193
- """Load all tables from the current database"""
1194
- try:
1195
- if self.current_connection_type == 'sqlite':
1196
- query = "SELECT name FROM sqlite_master WHERE type='table'"
1197
- cursor = self.conn.cursor()
1198
- tables = cursor.execute(query).fetchall()
1199
- for (table_name,) in tables:
1200
- self.loaded_tables[table_name] = 'database'
1201
- self.tables_list.addItem(f"{table_name} (database)")
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)
1202
831
 
1203
- # Get column names for each table
1204
- try:
1205
- column_query = f"PRAGMA table_info({table_name})"
1206
- columns = cursor.execute(column_query).fetchall()
1207
- self.table_columns[table_name] = [col[1] for col in columns] # Column name is at index 1
1208
- except Exception:
1209
- self.table_columns[table_name] = []
1210
- else: # duckdb
1211
- query = "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
1212
- result = self.conn.execute(query).fetchdf()
1213
- for table_name in result['table_name']:
1214
- self.loaded_tables[table_name] = 'database'
1215
- self.tables_list.addItem(f"{table_name} (database)")
832
+ # Use the database manager to open the database
833
+ self.db_manager.open_database(filename)
1216
834
 
1217
- # Get column names for each table
1218
- try:
1219
- column_query = f"SELECT column_name FROM information_schema.columns WHERE table_name='{table_name}' AND table_schema='main'"
1220
- columns = self.conn.execute(column_query).fetchdf()
1221
- self.table_columns[table_name] = columns['column_name'].tolist()
1222
- except Exception:
1223
- self.table_columns[table_name] = []
1224
-
1225
- # Update the completer with table and column names
1226
- self.update_completer()
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
+
1227
852
  except Exception as e:
1228
- self.statusBar().showMessage(f'Error loading tables: {str(e)}')
853
+ QMessageBox.critical(self, "Error",
854
+ f"Unexpected error:\n\n{str(e)}")
855
+ self.statusBar().showMessage("Error opening database")
1229
856
 
1230
857
  def update_completer(self):
1231
- """Update the completer with table and column names"""
1232
- # Collect all table names and column names
1233
- completion_words = list(self.loaded_tables.keys())
1234
-
1235
- # Add column names with table name prefix (for joins)
1236
- for table, columns in self.table_columns.items():
1237
- completion_words.extend(columns)
1238
- completion_words.extend([f"{table}.{col}" for col in columns])
858
+ """Update the completer with table and column names in a non-blocking way"""
859
+ try:
860
+ # Check if any tabs exist
861
+ if self.tab_widget.count() == 0:
862
+ return
863
+
864
+ # Start a background update with a timer
865
+ self.statusBar().showMessage("Updating auto-completion...", 2000)
866
+
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
871
+
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]
947
+
948
+ # Use a single model for all tabs to save memory and improve performance
949
+ model = QStringListModel(completion_words)
1239
950
 
1240
- # Update the completer in the query editor
1241
- self.query_edit.update_completer_model(completion_words)
951
+ # Keep a reference to the model to prevent garbage collection
952
+ self._current_completer_model = model
953
+
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'):
959
+ try:
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
+
1004
+ except Exception as e:
1005
+ # Catch any errors to prevent hanging
1006
+ self.statusBar().showMessage(f"Auto-completion update error: {str(e)}", 2000)
1007
+ return False
1008
+
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
1242
1024
 
1243
1025
  def execute_query(self):
1244
1026
  try:
1245
- query = self.query_edit.toPlainText().strip()
1027
+ # Get the current tab
1028
+ current_tab = self.get_current_tab()
1029
+ if not current_tab:
1030
+ return
1031
+
1032
+ query = current_tab.get_query_text().strip()
1246
1033
  if not query:
1247
1034
  QMessageBox.warning(self, "Empty Query", "Please enter a SQL query to execute.")
1248
1035
  return
@@ -1250,29 +1037,24 @@ class SQLShell(QMainWindow):
1250
1037
  start_time = datetime.now()
1251
1038
 
1252
1039
  try:
1253
- if self.current_connection_type == "duckdb":
1254
- result = self.conn.execute(query).fetchdf()
1255
- else: # sqlite
1256
- result = pd.read_sql_query(query, self.conn)
1040
+ # Use the database manager to execute the query
1041
+ result = self.db_manager.execute_query(query)
1257
1042
 
1258
1043
  execution_time = (datetime.now() - start_time).total_seconds()
1259
1044
  self.populate_table(result)
1260
1045
  self.statusBar().showMessage(f"Query executed successfully. Time: {execution_time:.2f}s. Rows: {len(result)}")
1261
1046
 
1262
- except (duckdb.Error, sqlite3.Error) as e:
1263
- error_msg = str(e)
1264
- if "syntax error" in error_msg.lower():
1265
- QMessageBox.critical(self, "SQL Syntax Error",
1266
- f"There is a syntax error in your query:\n\n{error_msg}")
1267
- elif "no such table" in error_msg.lower():
1268
- QMessageBox.critical(self, "Table Not Found",
1269
- f"The referenced table does not exist:\n\n{error_msg}")
1270
- elif "no such column" in error_msg.lower():
1271
- QMessageBox.critical(self, "Column Not Found",
1272
- f"The referenced column does not exist:\n\n{error_msg}")
1273
- else:
1274
- QMessageBox.critical(self, "Database Error",
1275
- f"An error occurred while executing the query:\n\n{error_msg}")
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))
1276
1058
  self.statusBar().showMessage("Query execution failed")
1277
1059
 
1278
1060
  except Exception as e:
@@ -1280,59 +1062,129 @@ class SQLShell(QMainWindow):
1280
1062
  f"An unexpected error occurred:\n\n{str(e)}")
1281
1063
  self.statusBar().showMessage("Query execution failed")
1282
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
+
1283
1128
  def clear_query(self):
1284
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
+
1285
1135
  # Save current text for animation
1286
- current_text = self.query_edit.toPlainText()
1136
+ current_text = current_tab.get_query_text()
1287
1137
  if not current_text:
1288
1138
  return
1289
1139
 
1290
1140
  # Clear the editor
1291
- self.query_edit.clear()
1141
+ current_tab.set_query_text("")
1292
1142
 
1293
1143
  # Show success message
1294
1144
  self.statusBar().showMessage('Query cleared', 2000) # Show for 2 seconds
1295
1145
 
1296
1146
  def show_table_preview(self, item):
1297
1147
  """Show a preview of the selected table"""
1298
- if item:
1299
- table_name = item.text().split(' (')[0]
1300
- try:
1301
- if self.current_connection_type == 'sqlite':
1302
- preview_df = pd.read_sql_query(f'SELECT * FROM "{table_name}" LIMIT 5', self.conn)
1303
- else:
1304
- preview_df = self.conn.execute(f'SELECT * FROM {table_name} LIMIT 5').fetchdf()
1305
-
1306
- self.populate_table(preview_df)
1307
- self.statusBar().showMessage(f'Showing preview of table "{table_name}"')
1308
-
1309
- # Update the results title to show which table is being previewed
1310
- results_title = self.findChild(QLabel, "header_label", Qt.FindChildOption.FindChildrenRecursively)
1311
- if results_title and results_title.text() == "RESULTS":
1312
- 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)
1313
1160
 
1314
- except Exception as e:
1315
- self.results_table.setRowCount(0)
1316
- self.results_table.setColumnCount(0)
1317
- self.row_count_label.setText("")
1318
- self.statusBar().showMessage('Error showing table preview')
1319
-
1320
- # Show error message with modern styling
1321
- QMessageBox.critical(
1322
- self,
1323
- "Error",
1324
- f"Error showing preview: {str(e)}",
1325
- QMessageBox.StandardButton.Ok
1326
- )
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
+ )
1327
1180
 
1328
1181
  def load_test_data(self):
1329
1182
  """Generate and load test data"""
1330
1183
  try:
1331
1184
  # Ensure we have a DuckDB connection
1332
- if not self.conn or self.current_connection_type != 'duckdb':
1333
- self.conn = duckdb.connect(':memory:')
1334
- self.current_connection_type = 'duckdb'
1335
- self.db_info_label.setText("Connected to: in-memory DuckDB")
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)
1336
1188
 
1337
1189
  # Show loading indicator
1338
1190
  self.statusBar().showMessage('Generating test data...')
@@ -1350,28 +1202,20 @@ class SQLShell(QMainWindow):
1350
1202
  customer_df.to_parquet('test_data/customer_data.parquet', index=False)
1351
1203
  product_df.to_excel('test_data/product_catalog.xlsx', index=False)
1352
1204
 
1353
- # Load the files into DuckDB
1354
- self.conn.register('sample_sales_data', sales_df)
1355
- self.conn.register('product_catalog', product_df)
1356
- self.conn.register('customer_data', customer_df)
1357
-
1358
- # Update loaded tables tracking
1359
- self.loaded_tables['sample_sales_data'] = 'test_data/sample_sales_data.xlsx'
1360
- self.loaded_tables['product_catalog'] = 'test_data/product_catalog.xlsx'
1361
- self.loaded_tables['customer_data'] = 'test_data/customer_data.parquet'
1362
-
1363
- # Store column names
1364
- self.table_columns['sample_sales_data'] = sales_df.columns.tolist()
1365
- self.table_columns['product_catalog'] = product_df.columns.tolist()
1366
- 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')
1367
1209
 
1368
1210
  # Update UI
1369
1211
  self.tables_list.clear()
1370
- for table_name, file_path in self.loaded_tables.items():
1212
+ for table_name, file_path in self.db_manager.loaded_tables.items():
1371
1213
  self.tables_list.addItem(f"{table_name} ({os.path.basename(file_path)})")
1372
1214
 
1373
- # Set the sample query
1374
- 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 = """
1375
1219
  SELECT
1376
1220
  DISTINCT
1377
1221
  c.customername
@@ -1381,7 +1225,7 @@ FROM
1381
1225
  INNER JOIN product_catalog p ON p.productid = s.productid
1382
1226
  LIMIT 10
1383
1227
  """
1384
- self.query_edit.setPlainText(sample_query.strip())
1228
+ current_tab.set_query_text(sample_query.strip())
1385
1229
 
1386
1230
  # Update completer
1387
1231
  self.update_completer()
@@ -1397,7 +1241,12 @@ LIMIT 10
1397
1241
  QMessageBox.critical(self, "Error", f"Failed to load test data: {str(e)}")
1398
1242
 
1399
1243
  def export_to_excel(self):
1400
- 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:
1401
1250
  QMessageBox.warning(self, "No Data", "There is no data to export.")
1402
1251
  return
1403
1252
 
@@ -1415,21 +1264,21 @@ LIMIT 10
1415
1264
 
1416
1265
  # Generate table name from file name
1417
1266
  base_name = os.path.splitext(os.path.basename(file_name))[0]
1418
- table_name = self.sanitize_table_name(base_name)
1267
+ table_name = self.db_manager.sanitize_table_name(base_name)
1419
1268
 
1420
1269
  # Ensure unique table name
1421
1270
  original_name = table_name
1422
1271
  counter = 1
1423
- while table_name in self.loaded_tables:
1272
+ while table_name in self.db_manager.loaded_tables:
1424
1273
  table_name = f"{original_name}_{counter}"
1425
1274
  counter += 1
1426
1275
 
1427
- # Register the table in DuckDB
1428
- self.conn.register(table_name, df)
1276
+ # Register the table in the database manager
1277
+ self.db_manager.register_dataframe(df, table_name, file_name)
1429
1278
 
1430
1279
  # Update tracking
1431
- self.loaded_tables[table_name] = file_name
1432
- self.table_columns[table_name] = df.columns.tolist()
1280
+ self.db_manager.loaded_tables[table_name] = file_name
1281
+ self.db_manager.table_columns[table_name] = df.columns.tolist()
1433
1282
 
1434
1283
  # Update UI
1435
1284
  self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
@@ -1450,7 +1299,12 @@ LIMIT 10
1450
1299
  self.statusBar().showMessage('Error exporting data')
1451
1300
 
1452
1301
  def export_to_parquet(self):
1453
- 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:
1454
1308
  QMessageBox.warning(self, "No Data", "There is no data to export.")
1455
1309
  return
1456
1310
 
@@ -1468,21 +1322,21 @@ LIMIT 10
1468
1322
 
1469
1323
  # Generate table name from file name
1470
1324
  base_name = os.path.splitext(os.path.basename(file_name))[0]
1471
- table_name = self.sanitize_table_name(base_name)
1325
+ table_name = self.db_manager.sanitize_table_name(base_name)
1472
1326
 
1473
1327
  # Ensure unique table name
1474
1328
  original_name = table_name
1475
1329
  counter = 1
1476
- while table_name in self.loaded_tables:
1330
+ while table_name in self.db_manager.loaded_tables:
1477
1331
  table_name = f"{original_name}_{counter}"
1478
1332
  counter += 1
1479
1333
 
1480
- # Register the table in DuckDB
1481
- self.conn.register(table_name, df)
1334
+ # Register the table in the database manager
1335
+ self.db_manager.register_dataframe(df, table_name, file_name)
1482
1336
 
1483
1337
  # Update tracking
1484
- self.loaded_tables[table_name] = file_name
1485
- self.table_columns[table_name] = df.columns.tolist()
1338
+ self.db_manager.loaded_tables[table_name] = file_name
1339
+ self.db_manager.table_columns[table_name] = df.columns.tolist()
1486
1340
 
1487
1341
  # Update UI
1488
1342
  self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
@@ -1504,12 +1358,17 @@ LIMIT 10
1504
1358
 
1505
1359
  def get_table_data_as_dataframe(self):
1506
1360
  """Helper function to convert table widget data to a DataFrame"""
1507
- 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())]
1508
1367
  data = []
1509
- for row in range(self.results_table.rowCount()):
1368
+ for row in range(current_tab.results_table.rowCount()):
1510
1369
  row_data = []
1511
- for column in range(self.results_table.columnCount()):
1512
- 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)
1513
1372
  row_data.append(item.text() if item else '')
1514
1373
  data.append(row_data)
1515
1374
  return pd.DataFrame(data, columns=headers)
@@ -1518,12 +1377,27 @@ LIMIT 10
1518
1377
  """Handle global keyboard shortcuts"""
1519
1378
  # Execute query with Ctrl+Enter or Cmd+Enter (for Mac)
1520
1379
  if event.key() == Qt.Key.Key_Return and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1521
- self.execute_btn.click() # Simply click the button instead of animating
1380
+ self.execute_query()
1522
1381
  return
1523
1382
 
1524
- # Clear query with Ctrl+L
1525
- if event.key() == Qt.Key.Key_L and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1526
- 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()
1527
1401
  return
1528
1402
 
1529
1403
  super().keyPressEvent(event)
@@ -1545,12 +1419,11 @@ LIMIT 10
1545
1419
  event.ignore()
1546
1420
  return
1547
1421
 
1422
+ # Save window state and settings
1423
+ self.save_recent_projects()
1424
+
1548
1425
  # Close database connections
1549
- if self.conn:
1550
- if self.current_connection_type == "duckdb":
1551
- self.conn.close()
1552
- else: # sqlite
1553
- self.conn.close()
1426
+ self.db_manager.close_connection()
1554
1427
  event.accept()
1555
1428
  except Exception as e:
1556
1429
  QMessageBox.warning(self, "Cleanup Warning",
@@ -1560,27 +1433,54 @@ LIMIT 10
1560
1433
  def has_unsaved_changes(self):
1561
1434
  """Check if there are unsaved changes in the project"""
1562
1435
  if not self.current_project_file:
1563
- return bool(self.loaded_tables or self.query_edit.toPlainText().strip())
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)
1564
1438
 
1565
1439
  try:
1566
1440
  # Load the last saved state
1567
1441
  with open(self.current_project_file, 'r') as f:
1568
1442
  saved_data = json.load(f)
1569
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
+
1570
1454
  # Compare current state with saved state
1571
1455
  current_data = {
1572
1456
  'tables': {
1573
1457
  name: {
1574
1458
  'file_path': path,
1575
- 'columns': self.table_columns.get(name, [])
1459
+ 'columns': self.db_manager.table_columns.get(name, [])
1576
1460
  }
1577
- for name, path in self.loaded_tables.items()
1461
+ for name, path in self.db_manager.loaded_tables.items()
1578
1462
  },
1579
- 'query': self.query_edit.toPlainText(),
1580
- 'connection_type': self.current_connection_type
1463
+ 'tabs': current_tabs_data,
1464
+ 'connection_type': self.db_manager.connection_type
1581
1465
  }
1582
1466
 
1583
- return current_data != saved_data
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
1584
1484
 
1585
1485
  except Exception:
1586
1486
  # If there's any error reading the saved file, assume there are unsaved changes
@@ -1592,6 +1492,11 @@ LIMIT 10
1592
1492
  if not item:
1593
1493
  return
1594
1494
 
1495
+ # Get current tab
1496
+ current_tab = self.get_current_tab()
1497
+ if not current_tab:
1498
+ return
1499
+
1595
1500
  # Get table name without the file info in parentheses
1596
1501
  table_name = item.text().split(' (')[0]
1597
1502
 
@@ -1615,45 +1520,79 @@ LIMIT 10
1615
1520
  # Add menu actions
1616
1521
  select_from_action = context_menu.addAction("Select from")
1617
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"))
1618
1527
 
1619
1528
  # Show menu and get selected action
1620
1529
  action = context_menu.exec(self.tables_list.mapToGlobal(position))
1621
1530
 
1622
1531
  if action == select_from_action:
1623
1532
  # Insert "SELECT * FROM table_name" at cursor position
1624
- cursor = self.query_edit.textCursor()
1533
+ cursor = current_tab.query_edit.textCursor()
1625
1534
  cursor.insertText(f"SELECT * FROM {table_name}")
1626
- self.query_edit.setFocus()
1535
+ current_tab.query_edit.setFocus()
1627
1536
  elif action == add_to_editor_action:
1628
1537
  # Just insert the table name at cursor position
1629
- cursor = self.query_edit.textCursor()
1538
+ cursor = current_tab.query_edit.textCursor()
1630
1539
  cursor.insertText(table_name)
1631
- self.query_edit.setFocus()
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()
1632
1566
 
1633
1567
  def new_project(self):
1634
1568
  """Create a new project by clearing current state"""
1635
- if self.conn:
1569
+ if self.db_manager.is_connected():
1636
1570
  reply = QMessageBox.question(self, 'New Project',
1637
1571
  'Are you sure you want to start a new project? All unsaved changes will be lost.',
1638
1572
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1639
1573
  if reply == QMessageBox.StandardButton.Yes:
1640
1574
  # Close existing connection
1641
- if self.current_connection_type == "duckdb":
1642
- self.conn.close()
1643
- else: # sqlite
1644
- self.conn.close()
1575
+ self.db_manager.close_connection()
1645
1576
 
1646
1577
  # Reset state
1647
- self.conn = None
1648
- self.current_connection_type = None
1649
- self.loaded_tables.clear()
1650
- self.table_columns.clear()
1651
1578
  self.tables_list.clear()
1652
- self.query_edit.clear()
1653
- self.results_table.setRowCount(0)
1654
- self.results_table.setColumnCount(0)
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
+
1655
1593
  self.current_project_file = None
1656
1594
  self.setWindowTitle('SQL Shell')
1595
+ self.db_info_label.setText("No database connected")
1657
1596
  self.statusBar().showMessage('New project created')
1658
1597
 
1659
1598
  def save_project(self):
@@ -1683,14 +1622,29 @@ LIMIT 10
1683
1622
  def save_project_to_file(self, file_name):
1684
1623
  """Save project data to a file"""
1685
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
+
1686
1635
  project_data = {
1687
1636
  'tables': {},
1688
- 'query': self.query_edit.toPlainText(),
1689
- 'connection_type': self.current_connection_type
1637
+ 'tabs': tabs_data,
1638
+ 'connection_type': self.db_manager.connection_type,
1639
+ 'database_path': None # Initialize to None
1690
1640
  }
1691
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
+
1692
1646
  # Save table information
1693
- for table_name, file_path in self.loaded_tables.items():
1647
+ for table_name, file_path in self.db_manager.loaded_tables.items():
1694
1648
  # For database tables and query results, store the special identifier
1695
1649
  if file_path in ['database', 'query_result']:
1696
1650
  source_path = file_path
@@ -1700,103 +1654,683 @@ LIMIT 10
1700
1654
 
1701
1655
  project_data['tables'][table_name] = {
1702
1656
  'file_path': source_path,
1703
- 'columns': self.table_columns.get(table_name, [])
1657
+ 'columns': self.db_manager.table_columns.get(table_name, [])
1704
1658
  }
1705
1659
 
1706
1660
  with open(file_name, 'w') as f:
1707
1661
  json.dump(project_data, f, indent=4)
1708
1662
 
1663
+ # Add to recent projects
1664
+ self.add_recent_project(os.path.abspath(file_name))
1665
+
1709
1666
  self.statusBar().showMessage(f'Project saved to {file_name}')
1710
1667
 
1711
1668
  except Exception as e:
1712
1669
  QMessageBox.critical(self, "Error",
1713
1670
  f"Failed to save project:\n\n{str(e)}")
1714
1671
 
1715
- def open_project(self):
1672
+ def open_project(self, file_name=None):
1716
1673
  """Open a project file"""
1717
- file_name, _ = QFileDialog.getOpenFileName(
1718
- self,
1719
- "Open Project",
1720
- "",
1721
- "SQL Shell Project (*.sqls);;All Files (*)"
1722
- )
1674
+ if not file_name:
1675
+ file_name, _ = QFileDialog.getOpenFileName(
1676
+ self,
1677
+ "Open Project",
1678
+ "",
1679
+ "SQL Shell Project (*.sqls);;All Files (*)"
1680
+ )
1723
1681
 
1724
1682
  if file_name:
1725
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
1726
1692
  with open(file_name, 'r') as f:
1727
1693
  project_data = json.load(f)
1728
1694
 
1695
+ # Update progress
1696
+ progress.setValue(10)
1697
+ QApplication.processEvents()
1698
+
1729
1699
  # Start fresh
1730
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.")
1731
1751
 
1732
- # Create connection if needed
1733
- if not self.conn:
1734
- self.conn = duckdb.connect(':memory:')
1735
- self.current_connection_type = 'duckdb'
1736
- self.db_info_label.setText("Connected to: in-memory DuckDB")
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
1737
1761
 
1738
1762
  # Load tables
1739
- for table_name, table_info in project_data['tables'].items():
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}")
1740
1768
  file_path = table_info['file_path']
1741
1769
  try:
1742
1770
  if file_path == 'database':
1743
- # For tables from database, we need to recreate them from their data
1744
- # Execute a SELECT to get the data and recreate the table
1745
- query = f"SELECT * FROM {table_name}"
1746
- df = pd.read_sql_query(query, self.conn)
1747
- self.conn.register(table_name, df)
1748
- self.loaded_tables[table_name] = '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
1749
1785
  self.tables_list.addItem(f"{table_name} (database)")
1750
1786
  elif file_path == 'query_result':
1751
1787
  # For tables from query results, we'll need to re-run the query
1752
1788
  # For now, just note it as a query result table
1753
- self.loaded_tables[table_name] = 'query_result'
1789
+ self.db_manager.loaded_tables[table_name] = 'query_result'
1754
1790
  self.tables_list.addItem(f"{table_name} (query result)")
1755
1791
  elif os.path.exists(file_path):
1756
- # Load the file based on its extension
1757
- if file_path.endswith(('.xlsx', '.xls')):
1758
- df = pd.read_excel(file_path)
1759
- elif file_path.endswith('.csv'):
1760
- df = pd.read_csv(file_path)
1761
- elif file_path.endswith('.parquet'):
1762
- df = pd.read_parquet(file_path)
1763
- else:
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)}")
1764
1799
  continue
1765
-
1766
- # Register the table
1767
- self.conn.register(table_name, df)
1768
- self.loaded_tables[table_name] = file_path
1769
- self.tables_list.addItem(f"{table_name} ({os.path.basename(file_path)})")
1770
1800
  else:
1771
1801
  QMessageBox.warning(self, "Warning",
1772
1802
  f"Could not find file for table {table_name}: {file_path}")
1773
1803
  continue
1774
1804
 
1775
- # Store the columns
1776
- self.table_columns[table_name] = table_info['columns']
1777
-
1778
1805
  except Exception as e:
1779
1806
  QMessageBox.warning(self, "Warning",
1780
1807
  f"Failed to load table {table_name}:\n{str(e)}")
1781
1808
 
1782
- # Restore query
1783
- if 'query' in project_data:
1784
- self.query_edit.setPlainText(project_data['query'])
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()
1785
1894
 
1786
1895
  # Update UI
1787
1896
  self.current_project_file = file_name
1788
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
+
1789
1922
  self.statusBar().showMessage(f'Project loaded from {file_name}')
1790
- self.update_completer()
1923
+ progress.close()
1791
1924
 
1792
1925
  except Exception as e:
1793
1926
  QMessageBox.critical(self, "Error",
1794
1927
  f"Failed to open project:\n\n{str(e)}")
1795
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
+
1796
2320
  def main():
1797
2321
  app = QApplication(sys.argv)
1798
2322
  app.setStyle(QStyleFactory.create('Fusion'))
1799
2323
 
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))
2333
+
1800
2334
  # Ensure we have a valid working directory with pool.db
1801
2335
  package_dir = os.path.dirname(os.path.abspath(__file__))
1802
2336
  working_dir = os.getcwd()
@@ -1812,22 +2346,71 @@ def main():
1812
2346
  if os.path.exists(package_db):
1813
2347
  shutil.copy2(package_db, working_dir)
1814
2348
 
1815
- # Show splash screen
1816
- splash = AnimatedSplashScreen()
1817
- splash.show()
1818
-
1819
- # Create and show main window after delay
1820
- timer = QTimer()
1821
- window = SQLShell()
1822
- timer.timeout.connect(lambda: show_main_window())
1823
- timer.start(2000) # 2 second delay
1824
-
1825
- def show_main_window():
2349
+ try:
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()
1826
2412
  window.show()
1827
- splash.finish(window)
1828
- timer.stop()
1829
-
1830
- sys.exit(app.exec())
2413
+ sys.exit(app.exec())
1831
2414
 
1832
2415
  if __name__ == '__main__':
1833
2416
  main()