sqlshell 0.1.8__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

sqlshell/main.py CHANGED
@@ -1,14 +1,15 @@
1
1
  import sys
2
2
  import os
3
3
  import json
4
+ import argparse
5
+ from pathlib import Path
6
+ import tempfile
4
7
 
5
8
  # Ensure proper path setup for resources when running directly
6
9
  if __name__ == "__main__":
7
10
  project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8
11
  sys.path.insert(0, project_root)
9
12
 
10
- import duckdb
11
- import sqlite3
12
13
  import pandas as pd
13
14
  from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
14
15
  QHBoxLayout, QTextEdit, QPushButton, QFileDialog,
@@ -16,10 +17,10 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
16
17
  QTableWidgetItem, QHeaderView, QMessageBox, QPlainTextEdit,
17
18
  QCompleter, QFrame, QToolButton, QSizePolicy, QTabWidget,
18
19
  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
20
+ QCheckBox, QWidgetAction, QMenuBar, QInputDialog, QProgressDialog,
21
+ QListWidgetItem, QDialog, QGraphicsDropShadowEffect, QTreeWidgetItem)
22
+ from PyQt6.QtCore import Qt, QAbstractTableModel, QRegularExpression, QRect, QSize, QStringListModel, QPropertyAnimation, QEasingCurve, QTimer, QPoint, QMimeData
23
+ from PyQt6.QtGui import QFont, QColor, QSyntaxHighlighter, QTextCharFormat, QPainter, QTextFormat, QTextCursor, QIcon, QPalette, QLinearGradient, QBrush, QPixmap, QPolygon, QPainterPath, QDrag
23
24
  import numpy as np
24
25
  from datetime import datetime
25
26
 
@@ -27,459 +28,40 @@ from sqlshell import create_test_data
27
28
  from sqlshell.splash_screen import AnimatedSplashScreen
28
29
  from sqlshell.syntax_highlighter import SQLSyntaxHighlighter
29
30
  from sqlshell.editor import LineNumberArea, SQLEditor
30
-
31
- class BarChartDelegate(QStyledItemDelegate):
32
- def __init__(self, parent=None):
33
- 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)
45
-
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 {
314
- 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;
381
- color: white;
382
- border: none;
383
- padding: 5px 15px;
384
- border-radius: 3px;
385
- }
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;
399
- }
400
- QPushButton:hover {
401
- background-color: #C0392B;
402
- }
403
- """)
404
-
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()}
418
-
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}")
31
+ from sqlshell.ui import FilterHeader, BarChartDelegate
32
+ from sqlshell.db import DatabaseManager
33
+ from sqlshell.query_tab import QueryTab
34
+ from sqlshell.styles import (get_application_stylesheet, get_tab_corner_stylesheet,
35
+ get_context_menu_stylesheet,
36
+ get_header_label_stylesheet, get_db_info_label_stylesheet,
37
+ get_tables_header_stylesheet, get_row_count_label_stylesheet)
38
+ from sqlshell.menus import setup_menubar
39
+ from sqlshell.table_list import DraggableTablesList
472
40
 
473
41
  class SQLShell(QMainWindow):
474
42
  def __init__(self):
475
43
  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
44
+ self.db_manager = DatabaseManager()
480
45
  self.current_df = None # Store the current DataFrame for filtering
481
46
  self.filter_widgets = [] # Store filter line edits
482
47
  self.current_project_file = None # Store the current project file path
48
+ self.recent_projects = [] # Store list of recent projects
49
+ self.max_recent_projects = 10 # Maximum number of recent projects to track
50
+ self.tabs = [] # Store list of all tabs
51
+
52
+ # User preferences
53
+ self.auto_load_recent_project = True # Default to auto-loading most recent project
54
+
55
+ # File tracking for quick access
56
+ self.recent_files = [] # Store list of recently opened files
57
+ self.frequent_files = {} # Store file paths with usage counts
58
+ self.max_recent_files = 15 # Maximum number of recent files to track
59
+
60
+ # Load recent projects from settings
61
+ self.load_recent_projects()
62
+
63
+ # Load recent and frequent files from settings
64
+ self.load_recent_files()
483
65
 
484
66
  # Define color scheme
485
67
  self.colors = {
@@ -499,192 +81,60 @@ class SQLShell(QMainWindow):
499
81
 
500
82
  self.init_ui()
501
83
  self.apply_stylesheet()
84
+
85
+ # Create initial tab
86
+ self.add_tab()
87
+
88
+ # Load most recent project if enabled and available
89
+ if self.auto_load_recent_project:
90
+ self.load_most_recent_project()
502
91
 
503
92
  def apply_stylesheet(self):
504
93
  """Apply custom stylesheet to the application"""
505
- self.setStyleSheet(f"""
506
- QMainWindow {{
507
- background-color: {self.colors['background']};
508
- }}
509
-
510
- QWidget {{
511
- color: {self.colors['text']};
512
- font-family: 'Segoe UI', 'Arial', sans-serif;
513
- }}
514
-
515
- QLabel {{
516
- font-size: 13px;
517
- padding: 2px;
518
- }}
519
-
520
- QLabel#header_label {{
521
- font-size: 16px;
522
- font-weight: bold;
523
- color: {self.colors['primary']};
524
- padding: 8px 0;
525
- }}
526
-
527
- QPushButton {{
528
- background-color: {self.colors['secondary']};
529
- color: white;
530
- border: none;
531
- border-radius: 4px;
532
- padding: 8px 16px;
533
- font-weight: bold;
534
- font-size: 13px;
535
- min-height: 30px;
536
- }}
537
-
538
- QPushButton:hover {{
539
- background-color: #2980B9;
540
- }}
541
-
542
- QPushButton:pressed {{
543
- background-color: #1F618D;
544
- }}
545
-
546
- QPushButton#primary_button {{
547
- background-color: {self.colors['accent']};
548
- }}
549
-
550
- QPushButton#primary_button:hover {{
551
- background-color: #16A085;
552
- }}
553
-
554
- QPushButton#primary_button:pressed {{
555
- background-color: #0E6655;
556
- }}
557
-
558
- QPushButton#danger_button {{
559
- background-color: {self.colors['error']};
560
- }}
561
-
562
- QPushButton#danger_button:hover {{
563
- background-color: #CB4335;
564
- }}
565
-
566
- QToolButton {{
567
- background-color: transparent;
568
- border: none;
569
- border-radius: 4px;
570
- padding: 4px;
571
- }}
572
-
573
- QToolButton:hover {{
574
- background-color: rgba(52, 152, 219, 0.2);
575
- }}
576
-
577
- QFrame#sidebar {{
578
- background-color: {self.colors['primary']};
579
- border-radius: 0px;
580
- }}
581
-
582
- QFrame#content_panel {{
583
- background-color: white;
584
- border-radius: 8px;
585
- border: 1px solid {self.colors['border']};
586
- }}
587
-
588
- QListWidget {{
589
- background-color: white;
590
- border-radius: 4px;
591
- border: 1px solid {self.colors['border']};
592
- padding: 4px;
593
- outline: none;
594
- }}
595
-
596
- QListWidget::item {{
597
- padding: 8px;
598
- border-radius: 4px;
599
- }}
600
-
601
- QListWidget::item:selected {{
602
- background-color: {self.colors['secondary']};
603
- color: white;
604
- }}
605
-
606
- QListWidget::item:hover:!selected {{
607
- background-color: #E3F2FD;
608
- }}
609
-
610
- QTableWidget {{
611
- background-color: white;
612
- alternate-background-color: #F8F9FA;
613
- border-radius: 4px;
614
- border: 1px solid {self.colors['border']};
615
- gridline-color: #E0E0E0;
616
- outline: none;
617
- }}
618
-
619
- QTableWidget::item {{
620
- padding: 4px;
621
- }}
622
-
623
- QTableWidget::item:selected {{
624
- background-color: rgba(52, 152, 219, 0.2);
625
- color: {self.colors['text']};
626
- }}
627
-
628
- QHeaderView::section {{
629
- background-color: {self.colors['primary']};
630
- color: white;
631
- padding: 8px;
632
- border: none;
633
- font-weight: bold;
634
- }}
635
-
636
- QSplitter::handle {{
637
- background-color: {self.colors['border']};
638
- }}
639
-
640
- QStatusBar {{
641
- background-color: {self.colors['primary']};
642
- color: white;
643
- padding: 8px;
644
- }}
645
-
646
- QPlainTextEdit, QTextEdit {{
647
- background-color: white;
648
- border-radius: 4px;
649
- border: 1px solid {self.colors['border']};
650
- padding: 8px;
651
- selection-background-color: #BBDEFB;
652
- selection-color: {self.colors['text']};
653
- font-family: 'Consolas', 'Courier New', monospace;
654
- font-size: 14px;
655
- }}
656
- """)
94
+ self.setStyleSheet(get_application_stylesheet(self.colors))
657
95
 
658
96
  def init_ui(self):
659
97
  self.setWindowTitle('SQL Shell')
660
- self.setGeometry(100, 100, 1400, 800)
661
98
 
662
- # Create menu bar
663
- menubar = self.menuBar()
664
- file_menu = menubar.addMenu('&File')
665
-
666
- # Project management actions
667
- new_project_action = file_menu.addAction('New Project')
668
- new_project_action.setShortcut('Ctrl+N')
669
- new_project_action.triggered.connect(self.new_project)
670
-
671
- open_project_action = file_menu.addAction('Open Project...')
672
- open_project_action.setShortcut('Ctrl+O')
673
- open_project_action.triggered.connect(self.open_project)
99
+ # Get screen geometry for smart sizing
100
+ screen = QApplication.primaryScreen()
101
+ screen_geometry = screen.availableGeometry()
102
+ screen_width = screen_geometry.width()
103
+ screen_height = screen_geometry.height()
104
+
105
+ # Calculate adaptive window size based on screen size
106
+ # Use 85% of screen size for larger screens, fixed size for smaller screens
107
+ if screen_width >= 1920 and screen_height >= 1080: # Larger screens
108
+ window_width = int(screen_width * 0.85)
109
+ window_height = int(screen_height * 0.85)
110
+ self.setGeometry(
111
+ (screen_width - window_width) // 2, # Center horizontally
112
+ (screen_height - window_height) // 2, # Center vertically
113
+ window_width,
114
+ window_height
115
+ )
116
+ else: # Default for smaller screens
117
+ self.setGeometry(100, 100, 1400, 800)
674
118
 
675
- save_project_action = file_menu.addAction('Save Project')
676
- save_project_action.setShortcut('Ctrl+S')
677
- save_project_action.triggered.connect(self.save_project)
119
+ # Remember if the window was maximized
120
+ self.was_maximized = False
678
121
 
679
- save_project_as_action = file_menu.addAction('Save Project As...')
680
- save_project_as_action.setShortcut('Ctrl+Shift+S')
681
- save_project_as_action.triggered.connect(self.save_project_as)
122
+ # Set application icon
123
+ icon_path = os.path.join(os.path.dirname(__file__), "resources", "icon.png")
124
+ if os.path.exists(icon_path):
125
+ self.setWindowIcon(QIcon(icon_path))
126
+ else:
127
+ # Fallback to the main logo if the icon isn't found
128
+ main_logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "sqlshell_logo.png")
129
+ if os.path.exists(main_logo_path):
130
+ self.setWindowIcon(QIcon(main_logo_path))
682
131
 
683
- file_menu.addSeparator()
132
+ # Setup menus
133
+ setup_menubar(self)
684
134
 
685
- exit_action = file_menu.addAction('Exit')
686
- exit_action.setShortcut('Ctrl+Q')
687
- exit_action.triggered.connect(self.close)
135
+ # Update quick access menu
136
+ if hasattr(self, 'quick_access_menu'):
137
+ self.update_quick_access_menu()
688
138
 
689
139
  # Create custom status bar
690
140
  status_bar = QStatusBar()
@@ -709,67 +159,37 @@ class SQLShell(QMainWindow):
709
159
  # Database info section
710
160
  db_header = QLabel("DATABASE")
711
161
  db_header.setObjectName("header_label")
712
- db_header.setStyleSheet("color: white;")
162
+ db_header.setStyleSheet(get_header_label_stylesheet())
713
163
  left_layout.addWidget(db_header)
714
164
 
715
165
  self.db_info_label = QLabel("No database connected")
716
- self.db_info_label.setStyleSheet("color: white; background-color: rgba(255, 255, 255, 0.1); padding: 8px; border-radius: 4px;")
166
+ self.db_info_label.setStyleSheet(get_db_info_label_stylesheet())
717
167
  left_layout.addWidget(self.db_info_label)
718
168
 
719
169
  # Database action buttons
720
170
  db_buttons_layout = QHBoxLayout()
721
171
  db_buttons_layout.setSpacing(8)
722
172
 
723
- self.open_db_btn = QPushButton('Open Database')
724
- self.open_db_btn.setIcon(QIcon.fromTheme("document-open"))
725
- self.open_db_btn.clicked.connect(self.open_database)
173
+ self.load_btn = QPushButton('Load')
174
+ self.load_btn.setIcon(QIcon.fromTheme("document-open"))
175
+ self.load_btn.clicked.connect(self.show_load_dialog)
726
176
 
727
- self.test_btn = QPushButton('Load Test Data')
728
- self.test_btn.clicked.connect(self.load_test_data)
177
+ self.quick_access_btn = QPushButton('Quick Access')
178
+ self.quick_access_btn.setIcon(QIcon.fromTheme("document-open-recent"))
179
+ self.quick_access_btn.clicked.connect(self.show_quick_access_menu)
729
180
 
730
- db_buttons_layout.addWidget(self.open_db_btn)
731
- db_buttons_layout.addWidget(self.test_btn)
181
+ db_buttons_layout.addWidget(self.load_btn)
182
+ db_buttons_layout.addWidget(self.quick_access_btn)
732
183
  left_layout.addLayout(db_buttons_layout)
733
184
 
734
185
  # Tables section
735
186
  tables_header = QLabel("TABLES")
736
187
  tables_header.setObjectName("header_label")
737
- tables_header.setStyleSheet("color: white; margin-top: 16px;")
188
+ tables_header.setStyleSheet(get_tables_header_stylesheet())
738
189
  left_layout.addWidget(tables_header)
739
190
 
740
- # Table actions
741
- table_actions_layout = QHBoxLayout()
742
- table_actions_layout.setSpacing(8)
743
-
744
- self.browse_btn = QPushButton('Load Files')
745
- self.browse_btn.setIcon(QIcon.fromTheme("document-new"))
746
- self.browse_btn.clicked.connect(self.browse_files)
747
-
748
- self.remove_table_btn = QPushButton('Remove')
749
- self.remove_table_btn.setObjectName("danger_button")
750
- self.remove_table_btn.setIcon(QIcon.fromTheme("edit-delete"))
751
- self.remove_table_btn.clicked.connect(self.remove_selected_table)
752
-
753
- table_actions_layout.addWidget(self.browse_btn)
754
- table_actions_layout.addWidget(self.remove_table_btn)
755
- left_layout.addLayout(table_actions_layout)
756
-
757
191
  # 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
- """)
192
+ self.tables_list = DraggableTablesList(self)
773
193
  self.tables_list.itemClicked.connect(self.show_table_preview)
774
194
  self.tables_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
775
195
  self.tables_list.customContextMenuRequested.connect(self.show_tables_context_menu)
@@ -778,7 +198,7 @@ class SQLShell(QMainWindow):
778
198
  # Add spacer at the bottom
779
199
  left_layout.addStretch()
780
200
 
781
- # Right panel for query and results
201
+ # Right panel for query tabs and results
782
202
  right_panel = QFrame()
783
203
  right_panel.setObjectName("content_panel")
784
204
  right_layout = QVBoxLayout(right_panel)
@@ -790,143 +210,66 @@ class SQLShell(QMainWindow):
790
210
  query_header.setObjectName("header_label")
791
211
  right_layout.addWidget(query_header)
792
212
 
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)
213
+ # Create tab widget for multiple queries
214
+ self.tab_widget = QTabWidget()
215
+ self.tab_widget.setTabsClosable(True)
216
+ self.tab_widget.setMovable(True)
217
+ self.tab_widget.tabCloseRequested.connect(self.close_tab)
837
218
 
838
- # Results header with row count and export options
839
- results_header_layout = QHBoxLayout()
219
+ # Connect double-click signal for direct tab renaming
220
+ self.tab_widget.tabBarDoubleClicked.connect(self.handle_tab_double_click)
840
221
 
841
- results_title = QLabel("RESULTS")
842
- results_title.setObjectName("header_label")
222
+ # Add a "+" button to the tab bar
223
+ self.tab_widget.setCornerWidget(self.create_tab_corner_widget())
843
224
 
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)
891
-
892
- # Set initial sizes for splitter
893
- splitter.setSizes([300, 500])
894
-
895
- right_layout.addWidget(splitter)
225
+ right_layout.addWidget(self.tab_widget)
896
226
 
897
227
  # Add panels to main layout
898
228
  main_layout.addWidget(left_panel, 1)
899
229
  main_layout.addWidget(right_panel, 4)
900
230
 
901
231
  # 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
- )
232
+ self.statusBar().showMessage('Ready | Ctrl+Enter: Execute Query | Ctrl+K: Toggle Comment | Ctrl+T: New Tab | Ctrl+Shift+O: Quick Access Files')
233
+
234
+ def create_tab_corner_widget(self):
235
+ """Create a corner widget with a + button to add new tabs"""
236
+ corner_widget = QWidget()
237
+ layout = QHBoxLayout(corner_widget)
238
+ layout.setContentsMargins(0, 0, 0, 0)
239
+ layout.setSpacing(0)
240
+
241
+ add_tab_btn = QToolButton()
242
+ add_tab_btn.setText("+")
243
+ add_tab_btn.setToolTip("Add new tab (Ctrl+T)")
244
+ add_tab_btn.setStyleSheet(get_tab_corner_stylesheet())
245
+ add_tab_btn.clicked.connect(self.add_tab)
246
+
247
+ layout.addWidget(add_tab_btn)
248
+ return corner_widget
912
249
 
913
250
  def populate_table(self, df):
914
251
  """Populate the results table with DataFrame data using memory-efficient chunking"""
915
252
  try:
253
+ # Get the current tab
254
+ current_tab = self.get_current_tab()
255
+ if not current_tab:
256
+ return
257
+
916
258
  # Store the current DataFrame for filtering
917
- self.current_df = df.copy()
259
+ current_tab.current_df = df.copy()
260
+ self.current_df = df.copy() # Keep this for compatibility with existing code
918
261
 
919
262
  # Remember which columns had bar charts
920
- header = self.results_table.horizontalHeader()
263
+ header = current_tab.results_table.horizontalHeader()
921
264
  if isinstance(header, FilterHeader):
922
265
  columns_with_bars = header.columns_with_bars.copy()
923
266
  else:
924
267
  columns_with_bars = set()
925
268
 
926
269
  # Clear existing data
927
- self.results_table.clearContents()
928
- self.results_table.setRowCount(0)
929
- self.results_table.setColumnCount(0)
270
+ current_tab.results_table.clearContents()
271
+ current_tab.results_table.setRowCount(0)
272
+ current_tab.results_table.setColumnCount(0)
930
273
 
931
274
  if df.empty:
932
275
  self.statusBar().showMessage("Query returned no results")
@@ -935,11 +278,11 @@ class SQLShell(QMainWindow):
935
278
  # Set up the table dimensions
936
279
  row_count = len(df)
937
280
  col_count = len(df.columns)
938
- self.results_table.setColumnCount(col_count)
281
+ current_tab.results_table.setColumnCount(col_count)
939
282
 
940
283
  # Set column headers
941
284
  headers = [str(col) for col in df.columns]
942
- self.results_table.setHorizontalHeaderLabels(headers)
285
+ current_tab.results_table.setHorizontalHeaderLabels(headers)
943
286
 
944
287
  # Calculate chunk size (adjust based on available memory)
945
288
  CHUNK_SIZE = 1000
@@ -950,27 +293,30 @@ class SQLShell(QMainWindow):
950
293
  chunk = df.iloc[chunk_start:chunk_end]
951
294
 
952
295
  # Add rows for this chunk
953
- self.results_table.setRowCount(chunk_end)
296
+ current_tab.results_table.setRowCount(chunk_end)
954
297
 
955
298
  for row_idx, (_, row_data) in enumerate(chunk.iterrows(), start=chunk_start):
956
299
  for col_idx, value in enumerate(row_data):
957
300
  formatted_value = self.format_value(value)
958
301
  item = QTableWidgetItem(formatted_value)
959
- self.results_table.setItem(row_idx, col_idx, item)
302
+ current_tab.results_table.setItem(row_idx, col_idx, item)
960
303
 
961
304
  # Process events to keep UI responsive
962
305
  QApplication.processEvents()
963
306
 
964
307
  # Optimize column widths
965
- self.results_table.resizeColumnsToContents()
308
+ current_tab.results_table.resizeColumnsToContents()
966
309
 
967
310
  # Restore bar charts for columns that previously had them
968
- header = self.results_table.horizontalHeader()
311
+ header = current_tab.results_table.horizontalHeader()
969
312
  if isinstance(header, FilterHeader):
970
313
  for col_idx in columns_with_bars:
971
314
  if col_idx < col_count: # Only if column still exists
972
315
  header.toggle_bar_chart(col_idx)
973
316
 
317
+ # Update row count label
318
+ current_tab.row_count_label.setText(f"{row_count:,} rows")
319
+
974
320
  # Update status
975
321
  memory_usage = df.memory_usage(deep=True).sum() / (1024 * 1024) # Convert to MB
976
322
  self.statusBar().showMessage(
@@ -1029,11 +375,16 @@ class SQLShell(QMainWindow):
1029
375
  elif isinstance(value, (float, np.floating)):
1030
376
  if value.is_integer():
1031
377
  return str(int(value))
1032
- return f"{value:.6g}" # Use general format with up to 6 significant digits
378
+ # Display full number without scientific notation by using 'f' format
379
+ # Format large numbers with commas for better readability
380
+ if abs(value) >= 1000000:
381
+ return f"{value:,.2f}" # Format with commas and 2 decimal places
382
+ return f"{value:.6f}" # Use fixed-point notation with 6 decimal places
1033
383
  elif isinstance(value, (pd.Timestamp, datetime)):
1034
384
  return value.strftime("%Y-%m-%d %H:%M:%S")
1035
385
  elif isinstance(value, (np.integer, int)):
1036
- return str(value)
386
+ # Format large integers with commas for better readability
387
+ return f"{value:,}"
1037
388
  elif isinstance(value, bool):
1038
389
  return str(value)
1039
390
  elif isinstance(value, (bytes, bytearray)):
@@ -1041,11 +392,10 @@ class SQLShell(QMainWindow):
1041
392
  return str(value)
1042
393
 
1043
394
  def browse_files(self):
1044
- if not self.conn:
395
+ if not self.db_manager.is_connected():
1045
396
  # 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")
397
+ connection_info = self.db_manager.create_memory_connection()
398
+ self.db_info_label.setText(connection_info)
1049
399
 
1050
400
  file_names, _ = QFileDialog.getOpenFileNames(
1051
401
  self,
@@ -1056,41 +406,14 @@ class SQLShell(QMainWindow):
1056
406
 
1057
407
  for file_name in file_names:
1058
408
  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)
409
+ # Add to recent files
410
+ self.add_recent_file(file_name)
1086
411
 
1087
- self.loaded_tables[table_name] = file_name
412
+ # Use the database manager to load the file
413
+ table_name, df = self.db_manager.load_file(file_name)
1088
414
 
1089
- # Store column names
1090
- self.table_columns[table_name] = df.columns.tolist()
1091
-
1092
- # Update UI
1093
- self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
415
+ # Update UI using new method
416
+ self.tables_list.add_table_item(table_name, os.path.basename(file_name))
1094
417
  self.statusBar().showMessage(f'Loaded {file_name} as table "{table_name}"')
1095
418
 
1096
419
  # Show preview of loaded data
@@ -1113,48 +436,40 @@ class SQLShell(QMainWindow):
1113
436
  self.results_table.setColumnCount(0)
1114
437
  self.row_count_label.setText("")
1115
438
 
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
439
  def remove_selected_table(self):
1126
440
  current_item = self.tables_list.currentItem()
1127
- if current_item:
1128
- 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]
1136
- # Remove from list widget
1137
- self.tables_list.takeItem(self.tables_list.row(current_item))
1138
- self.statusBar().showMessage(f'Removed table "{table_name}"')
1139
- self.results_table.setRowCount(0)
1140
- self.results_table.setColumnCount(0)
1141
- self.row_count_label.setText("")
1142
-
1143
- # Update completer
1144
- self.update_completer()
441
+ if not current_item or self.tables_list.is_folder_item(current_item):
442
+ return
443
+
444
+ table_name = self.tables_list.get_table_name_from_item(current_item)
445
+ if not table_name:
446
+ return
447
+
448
+ if self.db_manager.remove_table(table_name):
449
+ # Remove from tree widget
450
+ parent = current_item.parent()
451
+ if parent:
452
+ parent.removeChild(current_item)
453
+ else:
454
+ index = self.tables_list.indexOfTopLevelItem(current_item)
455
+ if index >= 0:
456
+ self.tables_list.takeTopLevelItem(index)
457
+
458
+ self.statusBar().showMessage(f'Removed table "{table_name}"')
459
+
460
+ # Get the current tab and clear its results table
461
+ current_tab = self.get_current_tab()
462
+ if current_tab:
463
+ current_tab.results_table.setRowCount(0)
464
+ current_tab.results_table.setColumnCount(0)
465
+ current_tab.row_count_label.setText("")
466
+
467
+ # Update completer
468
+ self.update_completer()
1145
469
 
1146
470
  def open_database(self):
1147
471
  """Open a database connection with proper error handling and resource management"""
1148
472
  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
473
  filename, _ = QFileDialog.getOpenFileName(
1159
474
  self,
1160
475
  "Open Database",
@@ -1163,86 +478,154 @@ class SQLShell(QMainWindow):
1163
478
  )
1164
479
 
1165
480
  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)")
481
+ try:
482
+ # Add to recent files
483
+ self.add_recent_file(filename)
1202
484
 
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)")
485
+ # Clear existing database tables from the list widget
486
+ for i in range(self.tables_list.topLevelItemCount() - 1, -1, -1):
487
+ item = self.tables_list.topLevelItem(i)
488
+ if item and item.text(0).endswith('(database)'):
489
+ self.tables_list.takeTopLevelItem(i)
1216
490
 
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()
491
+ # Use the database manager to open the database
492
+ self.db_manager.open_database(filename, load_all_tables=True)
493
+
494
+ # Update UI with tables from the database
495
+ for table_name, source in self.db_manager.loaded_tables.items():
496
+ if source == 'database':
497
+ self.tables_list.add_table_item(table_name, "database")
498
+
499
+ # Update the completer with table and column names
500
+ self.update_completer()
501
+
502
+ # Update status bar
503
+ self.statusBar().showMessage(f"Connected to database: {filename}")
504
+ self.db_info_label.setText(self.db_manager.get_connection_info())
505
+
506
+ except Exception as e:
507
+ QMessageBox.critical(self, "Database Connection Error",
508
+ f"Failed to open database:\n\n{str(e)}")
509
+ self.statusBar().showMessage("Failed to open database")
510
+
1227
511
  except Exception as e:
1228
- self.statusBar().showMessage(f'Error loading tables: {str(e)}')
512
+ QMessageBox.critical(self, "Error",
513
+ f"Unexpected error:\n\n{str(e)}")
514
+ self.statusBar().showMessage("Error opening database")
1229
515
 
1230
516
  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])
517
+ """Update the completer with table and column names in a non-blocking way"""
518
+ try:
519
+ # Check if any tabs exist
520
+ if self.tab_widget.count() == 0:
521
+ return
522
+
523
+ # Import the suggestion manager
524
+ from sqlshell.suggester_integration import get_suggestion_manager
525
+
526
+ # Get the suggestion manager singleton
527
+ suggestion_mgr = get_suggestion_manager()
528
+
529
+ # Start a background update with a timer
530
+ self.statusBar().showMessage("Updating auto-completion...", 2000)
1239
531
 
1240
- # Update the completer in the query editor
1241
- self.query_edit.update_completer_model(completion_words)
532
+ # Track query history and frequently used terms
533
+ if not hasattr(self, 'query_history'):
534
+ self.query_history = []
535
+ self.completion_usage = {} # Track usage frequency
536
+
537
+ # Get schema information from the database manager
538
+ try:
539
+ # Get table and column information
540
+ tables = set(self.db_manager.loaded_tables.keys())
541
+ table_columns = self.db_manager.table_columns
542
+
543
+ # Get column data types if available
544
+ column_types = {}
545
+ for table, columns in self.db_manager.table_columns.items():
546
+ for col in columns:
547
+ qualified_name = f"{table}.{col}"
548
+ # Try to infer type from sample data
549
+ if hasattr(self.db_manager, 'sample_data') and table in self.db_manager.sample_data:
550
+ sample = self.db_manager.sample_data[table]
551
+ if col in sample.columns:
552
+ # Get data type from pandas
553
+ col_dtype = str(sample[col].dtype)
554
+ column_types[qualified_name] = col_dtype
555
+ # Also store unqualified name
556
+ column_types[col] = col_dtype
557
+
558
+ # Update the suggestion manager with schema information
559
+ suggestion_mgr.update_schema(tables, table_columns, column_types)
560
+
561
+ except Exception as e:
562
+ self.statusBar().showMessage(f"Error getting completions: {str(e)}", 2000)
563
+
564
+ # Get all completion words from basic system (for backward compatibility)
565
+ try:
566
+ completion_words = self.db_manager.get_all_table_columns()
567
+ except Exception as e:
568
+ self.statusBar().showMessage(f"Error getting completions: {str(e)}", 2000)
569
+ completion_words = []
570
+
571
+ # Add frequently used terms from query history with higher priority
572
+ if hasattr(self, 'completion_usage') and self.completion_usage:
573
+ # Get the most frequently used terms (top 100)
574
+ frequent_terms = sorted(
575
+ self.completion_usage.items(),
576
+ key=lambda x: x[1],
577
+ reverse=True
578
+ )[:100]
579
+
580
+ # Add these to our completion words
581
+ for term, count in frequent_terms:
582
+ suggestion_mgr.suggester.usage_counts[term] = count
583
+ if term not in completion_words:
584
+ completion_words.append(term)
585
+
586
+ # Create a single shared model for all tabs to save memory
587
+ model = QStringListModel(completion_words)
588
+
589
+ # Keep a reference to the model to prevent garbage collection
590
+ self._current_completer_model = model
591
+
592
+ # First unregister all existing editors to avoid duplicates
593
+ existing_editors = suggestion_mgr._editors.copy()
594
+ for editor_id in existing_editors:
595
+ suggestion_mgr.unregister_editor(editor_id)
596
+
597
+ # Register editors with the suggestion manager and update their completer models
598
+ for i in range(self.tab_widget.count()):
599
+ tab = self.tab_widget.widget(i)
600
+ if tab and hasattr(tab, 'query_edit'):
601
+ # Register this editor with the suggestion manager using a unique ID
602
+ editor_id = f"tab_{i}_{id(tab.query_edit)}"
603
+ suggestion_mgr.register_editor(tab.query_edit, editor_id)
604
+
605
+ # Update the basic completer model for backward compatibility
606
+ try:
607
+ tab.query_edit.update_completer_model(model)
608
+ except Exception as e:
609
+ self.statusBar().showMessage(f"Error updating completer for tab {i}: {str(e)}", 2000)
610
+
611
+ # Process events to keep UI responsive
612
+ QApplication.processEvents()
613
+
614
+ return True
615
+
616
+ except Exception as e:
617
+ # Catch any errors to prevent hanging
618
+ self.statusBar().showMessage(f"Auto-completion update error: {str(e)}", 2000)
619
+ return False
1242
620
 
1243
621
  def execute_query(self):
1244
622
  try:
1245
- query = self.query_edit.toPlainText().strip()
623
+ # Get the current tab
624
+ current_tab = self.get_current_tab()
625
+ if not current_tab:
626
+ return
627
+
628
+ query = current_tab.get_query_text().strip()
1246
629
  if not query:
1247
630
  QMessageBox.warning(self, "Empty Query", "Please enter a SQL query to execute.")
1248
631
  return
@@ -1250,29 +633,33 @@ class SQLShell(QMainWindow):
1250
633
  start_time = datetime.now()
1251
634
 
1252
635
  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)
636
+ # Use the database manager to execute the query
637
+ result = self.db_manager.execute_query(query)
1257
638
 
1258
639
  execution_time = (datetime.now() - start_time).total_seconds()
1259
640
  self.populate_table(result)
1260
641
  self.statusBar().showMessage(f"Query executed successfully. Time: {execution_time:.2f}s. Rows: {len(result)}")
1261
642
 
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}")
643
+ # Record query for context-aware suggestions
644
+ try:
645
+ from sqlshell.suggester_integration import get_suggestion_manager
646
+ suggestion_mgr = get_suggestion_manager()
647
+ suggestion_mgr.record_query(query)
648
+ except Exception as e:
649
+ # Don't let suggestion errors affect query execution
650
+ print(f"Error recording query for suggestions: {e}")
651
+
652
+ # Record query in history and update completion usage (legacy)
653
+ self._update_query_history(query)
654
+
655
+ except SyntaxError as e:
656
+ QMessageBox.critical(self, "SQL Syntax Error", str(e))
657
+ self.statusBar().showMessage("Query execution failed: syntax error")
658
+ except ValueError as e:
659
+ QMessageBox.critical(self, "Query Error", str(e))
660
+ self.statusBar().showMessage("Query execution failed")
661
+ except Exception as e:
662
+ QMessageBox.critical(self, "Database Error", str(e))
1276
663
  self.statusBar().showMessage("Query execution failed")
1277
664
 
1278
665
  except Exception as e:
@@ -1280,108 +667,197 @@ class SQLShell(QMainWindow):
1280
667
  f"An unexpected error occurred:\n\n{str(e)}")
1281
668
  self.statusBar().showMessage("Query execution failed")
1282
669
 
670
+ def _update_query_history(self, query):
671
+ """Update query history and track term usage for improved autocompletion"""
672
+ import re
673
+
674
+ # Initialize history if it doesn't exist
675
+ if not hasattr(self, 'query_history'):
676
+ self.query_history = []
677
+ self.completion_usage = {}
678
+
679
+ # Add query to history (limit to 100 queries)
680
+ self.query_history.append(query)
681
+ if len(self.query_history) > 100:
682
+ self.query_history.pop(0)
683
+
684
+ # Extract terms and patterns from the query to update usage frequency
685
+
686
+ # Extract table and column names
687
+ table_pattern = r'\b([a-zA-Z0-9_]+)\b\.([a-zA-Z0-9_]+)\b'
688
+ qualified_columns = re.findall(table_pattern, query)
689
+ for table, column in qualified_columns:
690
+ qualified_name = f"{table}.{column}"
691
+ self.completion_usage[qualified_name] = self.completion_usage.get(qualified_name, 0) + 1
692
+
693
+ # Also count the table and column separately
694
+ self.completion_usage[table] = self.completion_usage.get(table, 0) + 1
695
+ self.completion_usage[column] = self.completion_usage.get(column, 0) + 1
696
+
697
+ # Extract SQL keywords
698
+ keyword_pattern = r'\b([A-Z_]{2,})\b'
699
+ keywords = re.findall(keyword_pattern, query.upper())
700
+ for keyword in keywords:
701
+ self.completion_usage[keyword] = self.completion_usage.get(keyword, 0) + 1
702
+
703
+ # Extract common SQL patterns
704
+ patterns = [
705
+ r'(SELECT\s+.*?\s+FROM)',
706
+ r'(GROUP\s+BY\s+.*?(?:HAVING|ORDER|LIMIT|$))',
707
+ r'(ORDER\s+BY\s+.*?(?:LIMIT|$))',
708
+ r'(INNER\s+JOIN|LEFT\s+JOIN|RIGHT\s+JOIN|FULL\s+JOIN).*?ON\s+.*?=\s+.*?(?:WHERE|JOIN|GROUP|ORDER|LIMIT|$)',
709
+ r'(INSERT\s+INTO\s+.*?\s+VALUES)',
710
+ r'(UPDATE\s+.*?\s+SET\s+.*?\s+WHERE)',
711
+ r'(DELETE\s+FROM\s+.*?\s+WHERE)'
712
+ ]
713
+
714
+ for pattern in patterns:
715
+ matches = re.findall(pattern, query, re.IGNORECASE | re.DOTALL)
716
+ for match in matches:
717
+ # Normalize pattern by removing extra whitespace and converting to uppercase
718
+ normalized = re.sub(r'\s+', ' ', match).strip().upper()
719
+ if len(normalized) < 50: # Only track reasonably sized patterns
720
+ self.completion_usage[normalized] = self.completion_usage.get(normalized, 0) + 1
721
+
722
+ # Schedule an update of the completion model (but not too often to avoid performance issues)
723
+ if not hasattr(self, '_last_completer_update') or \
724
+ (datetime.now() - self._last_completer_update).total_seconds() > 30:
725
+ self._last_completer_update = datetime.now()
726
+
727
+ # Use a timer to delay the update to avoid blocking the UI
728
+ update_timer = QTimer()
729
+ update_timer.setSingleShot(True)
730
+ update_timer.timeout.connect(self.update_completer)
731
+ update_timer.start(1000) # Update after 1 second
732
+
1283
733
  def clear_query(self):
1284
734
  """Clear the query editor with animation"""
735
+ # Get the current tab
736
+ current_tab = self.get_current_tab()
737
+ if not current_tab:
738
+ return
739
+
1285
740
  # Save current text for animation
1286
- current_text = self.query_edit.toPlainText()
741
+ current_text = current_tab.get_query_text()
1287
742
  if not current_text:
1288
743
  return
1289
744
 
1290
745
  # Clear the editor
1291
- self.query_edit.clear()
746
+ current_tab.set_query_text("")
1292
747
 
1293
748
  # Show success message
1294
749
  self.statusBar().showMessage('Query cleared', 2000) # Show for 2 seconds
1295
750
 
1296
751
  def show_table_preview(self, item):
1297
752
  """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}"')
753
+ if not item or self.tables_list.is_folder_item(item):
754
+ return
755
+
756
+ # Get the current tab
757
+ current_tab = self.get_current_tab()
758
+ if not current_tab:
759
+ return
760
+
761
+ table_name = self.tables_list.get_table_name_from_item(item)
762
+ if not table_name:
763
+ return
764
+
765
+ # Check if this table needs to be reloaded first
766
+ if table_name in self.tables_list.tables_needing_reload:
767
+ # Reload the table immediately without asking
768
+ self.reload_selected_table(table_name)
1308
769
 
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}")
770
+ try:
771
+ # Use the database manager to get a preview of the table
772
+ preview_df = self.db_manager.get_table_preview(table_name)
1313
773
 
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
- )
774
+ self.populate_table(preview_df)
775
+ self.statusBar().showMessage(f'Showing preview of table "{table_name}"')
776
+
777
+ # Update the results title to show which table is being previewed
778
+ current_tab.results_title.setText(f"PREVIEW: {table_name}")
779
+
780
+ except Exception as e:
781
+ current_tab.results_table.setRowCount(0)
782
+ current_tab.results_table.setColumnCount(0)
783
+ current_tab.row_count_label.setText("")
784
+ self.statusBar().showMessage('Error showing table preview')
785
+
786
+ # Show error message with modern styling
787
+ QMessageBox.critical(
788
+ self,
789
+ "Error",
790
+ f"Error showing preview: {str(e)}",
791
+ QMessageBox.StandardButton.Ok
792
+ )
1327
793
 
1328
794
  def load_test_data(self):
1329
795
  """Generate and load test data"""
1330
796
  try:
1331
797
  # 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")
798
+ if not self.db_manager.is_connected() or self.db_manager.connection_type != 'duckdb':
799
+ connection_info = self.db_manager.create_memory_connection()
800
+ self.db_info_label.setText(connection_info)
1336
801
 
1337
802
  # Show loading indicator
1338
803
  self.statusBar().showMessage('Generating test data...')
1339
804
 
1340
- # Create test data directory if it doesn't exist
1341
- os.makedirs('test_data', exist_ok=True)
805
+ # Create temporary directory for test data
806
+ temp_dir = tempfile.mkdtemp(prefix='sqlshell_test_')
1342
807
 
1343
808
  # Generate test data
1344
809
  sales_df = create_test_data.create_sales_data()
1345
810
  customer_df = create_test_data.create_customer_data()
1346
811
  product_df = create_test_data.create_product_data()
812
+ large_numbers_df = create_test_data.create_large_numbers_data()
1347
813
 
1348
- # Save test data
1349
- sales_df.to_excel('test_data/sample_sales_data.xlsx', index=False)
1350
- customer_df.to_parquet('test_data/customer_data.parquet', index=False)
1351
- product_df.to_excel('test_data/product_catalog.xlsx', index=False)
1352
-
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)
814
+ # Save test data to temporary directory
815
+ sales_path = os.path.join(temp_dir, 'sample_sales_data.xlsx')
816
+ customer_path = os.path.join(temp_dir, 'customer_data.parquet')
817
+ product_path = os.path.join(temp_dir, 'product_catalog.xlsx')
818
+ large_numbers_path = os.path.join(temp_dir, 'large_numbers.xlsx')
1357
819
 
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'
820
+ sales_df.to_excel(sales_path, index=False)
821
+ customer_df.to_parquet(customer_path, index=False)
822
+ product_df.to_excel(product_path, index=False)
823
+ large_numbers_df.to_excel(large_numbers_path, index=False)
1362
824
 
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()
825
+ # Register the tables in the database manager
826
+ self.db_manager.register_dataframe(sales_df, 'sample_sales_data', sales_path)
827
+ self.db_manager.register_dataframe(product_df, 'product_catalog', product_path)
828
+ self.db_manager.register_dataframe(customer_df, 'customer_data', customer_path)
829
+ self.db_manager.register_dataframe(large_numbers_df, 'large_numbers', large_numbers_path)
1367
830
 
1368
831
  # Update UI
1369
832
  self.tables_list.clear()
1370
- for table_name, file_path in self.loaded_tables.items():
1371
- self.tables_list.addItem(f"{table_name} ({os.path.basename(file_path)})")
1372
-
1373
- # Set the sample query
1374
- sample_query = """
833
+ for table_name, file_path in self.db_manager.loaded_tables.items():
834
+ # Use the new add_table_item method
835
+ self.tables_list.add_table_item(table_name, os.path.basename(file_path))
836
+
837
+ # Set the sample query in the current tab
838
+ current_tab = self.get_current_tab()
839
+ if current_tab:
840
+ sample_query = """
841
+ -- Example query with tables containing large numbers
1375
842
  SELECT
1376
- DISTINCT
1377
- c.customername
843
+ ln.ID,
844
+ ln.Category,
845
+ ln.MediumValue,
846
+ ln.LargeValue,
847
+ ln.VeryLargeValue,
848
+ ln.MassiveValue,
849
+ ln.ExponentialValue,
850
+ ln.Revenue,
851
+ ln.Budget
1378
852
  FROM
1379
- sample_sales_data s
1380
- INNER JOIN customer_data c ON c.customerid = s.customerid
1381
- INNER JOIN product_catalog p ON p.productid = s.productid
853
+ large_numbers ln
854
+ WHERE
855
+ ln.LargeValue > 5000000000000
856
+ ORDER BY
857
+ ln.MassiveValue DESC
1382
858
  LIMIT 10
1383
859
  """
1384
- self.query_edit.setPlainText(sample_query.strip())
860
+ current_tab.set_query_text(sample_query.strip())
1385
861
 
1386
862
  # Update completer
1387
863
  self.update_completer()
@@ -1389,15 +865,22 @@ LIMIT 10
1389
865
  # Show success message
1390
866
  self.statusBar().showMessage('Test data loaded successfully')
1391
867
 
1392
- # Show a preview of the sales data
1393
- self.show_table_preview(self.tables_list.item(0))
868
+ # Show a preview of the large numbers data
869
+ large_numbers_item = self.tables_list.find_table_item("large_numbers")
870
+ if large_numbers_item:
871
+ self.show_table_preview(large_numbers_item)
1394
872
 
1395
873
  except Exception as e:
1396
874
  self.statusBar().showMessage(f'Error loading test data: {str(e)}')
1397
875
  QMessageBox.critical(self, "Error", f"Failed to load test data: {str(e)}")
1398
876
 
1399
877
  def export_to_excel(self):
1400
- if self.results_table.rowCount() == 0:
878
+ # Get the current tab
879
+ current_tab = self.get_current_tab()
880
+ if not current_tab:
881
+ return
882
+
883
+ if current_tab.results_table.rowCount() == 0:
1401
884
  QMessageBox.warning(self, "No Data", "There is no data to export.")
1402
885
  return
1403
886
 
@@ -1415,24 +898,24 @@ LIMIT 10
1415
898
 
1416
899
  # Generate table name from file name
1417
900
  base_name = os.path.splitext(os.path.basename(file_name))[0]
1418
- table_name = self.sanitize_table_name(base_name)
901
+ table_name = self.db_manager.sanitize_table_name(base_name)
1419
902
 
1420
903
  # Ensure unique table name
1421
904
  original_name = table_name
1422
905
  counter = 1
1423
- while table_name in self.loaded_tables:
906
+ while table_name in self.db_manager.loaded_tables:
1424
907
  table_name = f"{original_name}_{counter}"
1425
908
  counter += 1
1426
909
 
1427
- # Register the table in DuckDB
1428
- self.conn.register(table_name, df)
910
+ # Register the table in the database manager
911
+ self.db_manager.register_dataframe(df, table_name, file_name)
1429
912
 
1430
913
  # Update tracking
1431
- self.loaded_tables[table_name] = file_name
1432
- self.table_columns[table_name] = df.columns.tolist()
914
+ self.db_manager.loaded_tables[table_name] = file_name
915
+ self.db_manager.table_columns[table_name] = df.columns.tolist()
1433
916
 
1434
- # Update UI
1435
- self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
917
+ # Update UI using new method
918
+ self.tables_list.add_table_item(table_name, os.path.basename(file_name))
1436
919
  self.statusBar().showMessage(f'Data exported to {file_name} and loaded as table "{table_name}"')
1437
920
 
1438
921
  # Update completer with new table and column names
@@ -1450,7 +933,12 @@ LIMIT 10
1450
933
  self.statusBar().showMessage('Error exporting data')
1451
934
 
1452
935
  def export_to_parquet(self):
1453
- if self.results_table.rowCount() == 0:
936
+ # Get the current tab
937
+ current_tab = self.get_current_tab()
938
+ if not current_tab:
939
+ return
940
+
941
+ if current_tab.results_table.rowCount() == 0:
1454
942
  QMessageBox.warning(self, "No Data", "There is no data to export.")
1455
943
  return
1456
944
 
@@ -1468,24 +956,24 @@ LIMIT 10
1468
956
 
1469
957
  # Generate table name from file name
1470
958
  base_name = os.path.splitext(os.path.basename(file_name))[0]
1471
- table_name = self.sanitize_table_name(base_name)
959
+ table_name = self.db_manager.sanitize_table_name(base_name)
1472
960
 
1473
961
  # Ensure unique table name
1474
962
  original_name = table_name
1475
963
  counter = 1
1476
- while table_name in self.loaded_tables:
964
+ while table_name in self.db_manager.loaded_tables:
1477
965
  table_name = f"{original_name}_{counter}"
1478
966
  counter += 1
1479
967
 
1480
- # Register the table in DuckDB
1481
- self.conn.register(table_name, df)
968
+ # Register the table in the database manager
969
+ self.db_manager.register_dataframe(df, table_name, file_name)
1482
970
 
1483
971
  # Update tracking
1484
- self.loaded_tables[table_name] = file_name
1485
- self.table_columns[table_name] = df.columns.tolist()
972
+ self.db_manager.loaded_tables[table_name] = file_name
973
+ self.db_manager.table_columns[table_name] = df.columns.tolist()
1486
974
 
1487
- # Update UI
1488
- self.tables_list.addItem(f"{table_name} ({os.path.basename(file_name)})")
975
+ # Update UI using new method
976
+ self.tables_list.add_table_item(table_name, os.path.basename(file_name))
1489
977
  self.statusBar().showMessage(f'Data exported to {file_name} and loaded as table "{table_name}"')
1490
978
 
1491
979
  # Update completer with new table and column names
@@ -1503,27 +991,128 @@ LIMIT 10
1503
991
  self.statusBar().showMessage('Error exporting data')
1504
992
 
1505
993
  def get_table_data_as_dataframe(self):
1506
- """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())]
994
+ """Helper function to convert table widget data to a DataFrame with proper data types"""
995
+ # Get the current tab
996
+ current_tab = self.get_current_tab()
997
+ if not current_tab:
998
+ return pd.DataFrame()
999
+
1000
+ headers = [current_tab.results_table.horizontalHeaderItem(i).text() for i in range(current_tab.results_table.columnCount())]
1508
1001
  data = []
1509
- for row in range(self.results_table.rowCount()):
1002
+ for row in range(current_tab.results_table.rowCount()):
1510
1003
  row_data = []
1511
- for column in range(self.results_table.columnCount()):
1512
- item = self.results_table.item(row, column)
1004
+ for column in range(current_tab.results_table.columnCount()):
1005
+ item = current_tab.results_table.item(row, column)
1513
1006
  row_data.append(item.text() if item else '')
1514
1007
  data.append(row_data)
1515
- return pd.DataFrame(data, columns=headers)
1008
+
1009
+ # Create DataFrame from raw string data
1010
+ df_raw = pd.DataFrame(data, columns=headers)
1011
+
1012
+ # Try to use the original dataframe's dtypes if available
1013
+ if hasattr(current_tab, 'current_df') and current_tab.current_df is not None:
1014
+ original_df = current_tab.current_df
1015
+ # Since we might have filtered rows, we can't just return the original DataFrame
1016
+ # But we can use its column types to convert our string data appropriately
1017
+
1018
+ # Create a new DataFrame with appropriate types
1019
+ df_typed = pd.DataFrame()
1020
+
1021
+ for col in df_raw.columns:
1022
+ if col in original_df.columns:
1023
+ # Get the original column type
1024
+ orig_type = original_df[col].dtype
1025
+
1026
+ # Special handling for different data types
1027
+ if pd.api.types.is_numeric_dtype(orig_type):
1028
+ # Handle numeric columns (int or float)
1029
+ try:
1030
+ # First try to convert to numeric type
1031
+ # Remove commas used for thousands separators
1032
+ numeric_col = pd.to_numeric(df_raw[col].str.replace(',', '').replace('NULL', np.nan))
1033
+ df_typed[col] = numeric_col
1034
+ except:
1035
+ # If that fails, keep the original string
1036
+ df_typed[col] = df_raw[col]
1037
+ elif pd.api.types.is_datetime64_dtype(orig_type):
1038
+ # Handle datetime columns
1039
+ try:
1040
+ df_typed[col] = pd.to_datetime(df_raw[col].replace('NULL', np.nan))
1041
+ except:
1042
+ df_typed[col] = df_raw[col]
1043
+ elif pd.api.types.is_bool_dtype(orig_type):
1044
+ # Handle boolean columns
1045
+ try:
1046
+ df_typed[col] = df_raw[col].map({'True': True, 'False': False}).replace('NULL', np.nan)
1047
+ except:
1048
+ df_typed[col] = df_raw[col]
1049
+ else:
1050
+ # For other types, keep as is
1051
+ df_typed[col] = df_raw[col]
1052
+ else:
1053
+ # For columns not in the original dataframe, infer type
1054
+ df_typed[col] = df_raw[col]
1055
+
1056
+ return df_typed
1057
+
1058
+ else:
1059
+ # If we don't have the original dataframe, try to infer types
1060
+ # First replace 'NULL' with actual NaN
1061
+ df_raw.replace('NULL', np.nan, inplace=True)
1062
+
1063
+ # Try to convert each column to numeric if possible
1064
+ for col in df_raw.columns:
1065
+ try:
1066
+ # First try to convert to numeric by removing commas
1067
+ df_raw[col] = pd.to_numeric(df_raw[col].str.replace(',', ''))
1068
+ except:
1069
+ # If that fails, try to convert to datetime
1070
+ try:
1071
+ df_raw[col] = pd.to_datetime(df_raw[col])
1072
+ except:
1073
+ # If both numeric and datetime conversions fail,
1074
+ # try boolean conversion for True/False strings
1075
+ try:
1076
+ if df_raw[col].dropna().isin(['True', 'False']).all():
1077
+ df_raw[col] = df_raw[col].map({'True': True, 'False': False})
1078
+ except:
1079
+ # Otherwise, keep as is
1080
+ pass
1081
+
1082
+ return df_raw
1516
1083
 
1517
1084
  def keyPressEvent(self, event):
1518
1085
  """Handle global keyboard shortcuts"""
1519
1086
  # Execute query with Ctrl+Enter or Cmd+Enter (for Mac)
1520
1087
  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
1088
+ self.execute_query()
1089
+ return
1090
+
1091
+ # Add new tab with Ctrl+T
1092
+ if event.key() == Qt.Key.Key_T and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1093
+ self.add_tab()
1094
+ return
1095
+
1096
+ # Close current tab with Ctrl+W
1097
+ if event.key() == Qt.Key.Key_W and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1098
+ self.close_current_tab()
1099
+ return
1100
+
1101
+ # Duplicate tab with Ctrl+D
1102
+ if event.key() == Qt.Key.Key_D and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1103
+ self.duplicate_current_tab()
1104
+ return
1105
+
1106
+ # Rename tab with Ctrl+R
1107
+ if event.key() == Qt.Key.Key_R and (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
1108
+ self.rename_current_tab()
1522
1109
  return
1523
1110
 
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
1111
+ # Show quick access menu with Ctrl+Shift+O
1112
+ if (event.key() == Qt.Key.Key_O and
1113
+ (event.modifiers() & Qt.KeyboardModifier.ControlModifier) and
1114
+ (event.modifiers() & Qt.KeyboardModifier.ShiftModifier)):
1115
+ self.show_quick_access_menu()
1527
1116
  return
1528
1117
 
1529
1118
  super().keyPressEvent(event)
@@ -1545,12 +1134,11 @@ LIMIT 10
1545
1134
  event.ignore()
1546
1135
  return
1547
1136
 
1137
+ # Save window state and settings
1138
+ self.save_recent_projects()
1139
+
1548
1140
  # 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()
1141
+ self.db_manager.close_connection()
1554
1142
  event.accept()
1555
1143
  except Exception as e:
1556
1144
  QMessageBox.warning(self, "Cleanup Warning",
@@ -1560,27 +1148,54 @@ LIMIT 10
1560
1148
  def has_unsaved_changes(self):
1561
1149
  """Check if there are unsaved changes in the project"""
1562
1150
  if not self.current_project_file:
1563
- return bool(self.loaded_tables or self.query_edit.toPlainText().strip())
1151
+ return (self.tab_widget.count() > 0 and any(self.tab_widget.widget(i).get_query_text().strip()
1152
+ for i in range(self.tab_widget.count()))) or bool(self.db_manager.loaded_tables)
1564
1153
 
1565
1154
  try:
1566
1155
  # Load the last saved state
1567
1156
  with open(self.current_project_file, 'r') as f:
1568
1157
  saved_data = json.load(f)
1569
1158
 
1159
+ # Prepare current tab data
1160
+ current_tabs_data = []
1161
+ for i in range(self.tab_widget.count()):
1162
+ tab = self.tab_widget.widget(i)
1163
+ tab_data = {
1164
+ 'title': self.tab_widget.tabText(i),
1165
+ 'query': tab.get_query_text()
1166
+ }
1167
+ current_tabs_data.append(tab_data)
1168
+
1570
1169
  # Compare current state with saved state
1571
1170
  current_data = {
1572
1171
  'tables': {
1573
1172
  name: {
1574
1173
  'file_path': path,
1575
- 'columns': self.table_columns.get(name, [])
1174
+ 'columns': self.db_manager.table_columns.get(name, [])
1576
1175
  }
1577
- for name, path in self.loaded_tables.items()
1176
+ for name, path in self.db_manager.loaded_tables.items()
1578
1177
  },
1579
- 'query': self.query_edit.toPlainText(),
1580
- 'connection_type': self.current_connection_type
1178
+ 'tabs': current_tabs_data,
1179
+ 'connection_type': self.db_manager.connection_type
1581
1180
  }
1582
1181
 
1583
- return current_data != saved_data
1182
+ # Compare tables and connection type
1183
+ if (current_data['connection_type'] != saved_data.get('connection_type') or
1184
+ len(current_data['tables']) != len(saved_data.get('tables', {}))):
1185
+ return True
1186
+
1187
+ # Compare tab data
1188
+ if 'tabs' not in saved_data or len(current_data['tabs']) != len(saved_data['tabs']):
1189
+ return True
1190
+
1191
+ for i, tab_data in enumerate(current_data['tabs']):
1192
+ saved_tab = saved_data['tabs'][i]
1193
+ if (tab_data['title'] != saved_tab.get('title', '') or
1194
+ tab_data['query'] != saved_tab.get('query', '')):
1195
+ return True
1196
+
1197
+ # If we get here, everything matches
1198
+ return False
1584
1199
 
1585
1200
  except Exception:
1586
1201
  # If there's any error reading the saved file, assume there are unsaved changes
@@ -1589,72 +1204,254 @@ LIMIT 10
1589
1204
  def show_tables_context_menu(self, position):
1590
1205
  """Show context menu for tables list"""
1591
1206
  item = self.tables_list.itemAt(position)
1592
- if not item:
1207
+
1208
+ # If no item or it's a folder, let the tree widget handle it
1209
+ if not item or self.tables_list.is_folder_item(item):
1210
+ return
1211
+
1212
+ # Get current tab
1213
+ current_tab = self.get_current_tab()
1214
+ if not current_tab:
1593
1215
  return
1594
1216
 
1595
1217
  # Get table name without the file info in parentheses
1596
- table_name = item.text().split(' (')[0]
1218
+ table_name = self.tables_list.get_table_name_from_item(item)
1219
+ if not table_name:
1220
+ return
1597
1221
 
1598
1222
  # Create context menu
1599
1223
  context_menu = QMenu(self)
1600
- context_menu.setStyleSheet("""
1601
- QMenu {
1602
- background-color: white;
1603
- border: 1px solid #BDC3C7;
1604
- padding: 5px;
1605
- }
1606
- QMenu::item {
1607
- padding: 5px 20px;
1608
- }
1609
- QMenu::item:selected {
1610
- background-color: #3498DB;
1611
- color: white;
1612
- }
1613
- """)
1224
+ context_menu.setStyleSheet(get_context_menu_stylesheet())
1614
1225
 
1615
1226
  # Add menu actions
1616
1227
  select_from_action = context_menu.addAction("Select from")
1617
1228
  add_to_editor_action = context_menu.addAction("Just add to editor")
1229
+
1230
+ # Add entropy profiler action
1231
+ context_menu.addSeparator()
1232
+ analyze_entropy_action = context_menu.addAction("Analyze Column Importance")
1233
+ analyze_entropy_action.setIcon(QIcon.fromTheme("system-search"))
1234
+
1235
+ # Add table profiler action
1236
+ profile_table_action = context_menu.addAction("Profile Table Structure")
1237
+ profile_table_action.setIcon(QIcon.fromTheme("edit-find"))
1238
+
1239
+ # Check if table needs reloading and add appropriate action
1240
+ if table_name in self.tables_list.tables_needing_reload:
1241
+ reload_action = context_menu.addAction("Reload Table")
1242
+ reload_action.setIcon(QIcon.fromTheme("view-refresh"))
1243
+ else:
1244
+ reload_action = context_menu.addAction("Refresh")
1245
+ reload_action.setIcon(QIcon.fromTheme("view-refresh"))
1246
+
1247
+ # Add move to folder submenu
1248
+ move_menu = context_menu.addMenu("Move to Folder")
1249
+ move_menu.setIcon(QIcon.fromTheme("folder"))
1250
+
1251
+ # Add "New Folder" option to move menu
1252
+ new_folder_action = move_menu.addAction("New Folder...")
1253
+ move_menu.addSeparator()
1254
+
1255
+ # Add folders to the move menu
1256
+ for i in range(self.tables_list.topLevelItemCount()):
1257
+ top_item = self.tables_list.topLevelItem(i)
1258
+ if self.tables_list.is_folder_item(top_item):
1259
+ folder_action = move_menu.addAction(top_item.text(0))
1260
+ folder_action.setData(top_item)
1261
+
1262
+ # Add root option
1263
+ move_menu.addSeparator()
1264
+ root_action = move_menu.addAction("Root (No Folder)")
1265
+
1266
+ context_menu.addSeparator()
1267
+ rename_action = context_menu.addAction("Rename table...")
1268
+ delete_action = context_menu.addAction("Delete table")
1269
+ delete_action.setIcon(QIcon.fromTheme("edit-delete"))
1618
1270
 
1619
1271
  # Show menu and get selected action
1620
1272
  action = context_menu.exec(self.tables_list.mapToGlobal(position))
1621
1273
 
1622
1274
  if action == select_from_action:
1275
+ # Check if table needs reloading first
1276
+ if table_name in self.tables_list.tables_needing_reload:
1277
+ # Reload the table immediately without asking
1278
+ self.reload_selected_table(table_name)
1279
+
1623
1280
  # Insert "SELECT * FROM table_name" at cursor position
1624
- cursor = self.query_edit.textCursor()
1281
+ cursor = current_tab.query_edit.textCursor()
1625
1282
  cursor.insertText(f"SELECT * FROM {table_name}")
1626
- self.query_edit.setFocus()
1283
+ current_tab.query_edit.setFocus()
1627
1284
  elif action == add_to_editor_action:
1628
1285
  # Just insert the table name at cursor position
1629
- cursor = self.query_edit.textCursor()
1286
+ cursor = current_tab.query_edit.textCursor()
1630
1287
  cursor.insertText(table_name)
1631
- self.query_edit.setFocus()
1288
+ current_tab.query_edit.setFocus()
1289
+ elif action == reload_action:
1290
+ self.reload_selected_table(table_name)
1291
+ elif action == analyze_entropy_action:
1292
+ # Call the entropy analysis method
1293
+ self.analyze_table_entropy(table_name)
1294
+ elif action == profile_table_action:
1295
+ # Call the table profile method
1296
+ self.profile_table_structure(table_name)
1297
+ elif action == rename_action:
1298
+ # Show rename dialog
1299
+ new_name, ok = QInputDialog.getText(
1300
+ self,
1301
+ "Rename Table",
1302
+ "Enter new table name:",
1303
+ QLineEdit.EchoMode.Normal,
1304
+ table_name
1305
+ )
1306
+ if ok and new_name:
1307
+ if self.rename_table(table_name, new_name):
1308
+ # Update the item text
1309
+ source = item.text(0).split(' (')[1][:-1] # Get the source part
1310
+ item.setText(0, f"{new_name} ({source})")
1311
+ self.statusBar().showMessage(f'Table renamed to "{new_name}"')
1312
+ elif action == delete_action:
1313
+ # Show confirmation dialog
1314
+ reply = QMessageBox.question(
1315
+ self,
1316
+ "Delete Table",
1317
+ f"Are you sure you want to delete table '{table_name}'?",
1318
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
1319
+ )
1320
+ if reply == QMessageBox.StandardButton.Yes:
1321
+ self.remove_selected_table()
1322
+ elif action == new_folder_action:
1323
+ # Create a new folder and move the table there
1324
+ folder_name, ok = QInputDialog.getText(
1325
+ self,
1326
+ "New Folder",
1327
+ "Enter folder name:",
1328
+ QLineEdit.EchoMode.Normal
1329
+ )
1330
+ if ok and folder_name:
1331
+ folder = self.tables_list.create_folder(folder_name)
1332
+ self.tables_list.move_item_to_folder(item, folder)
1333
+ self.statusBar().showMessage(f'Moved table "{table_name}" to folder "{folder_name}"')
1334
+ elif action == root_action:
1335
+ # Move table to root (remove from any folder)
1336
+ parent = item.parent()
1337
+ if parent and self.tables_list.is_folder_item(parent):
1338
+ # Create a clone at root level
1339
+ source = item.text(0).split(' (')[1][:-1] # Get the source part
1340
+ needs_reload = table_name in self.tables_list.tables_needing_reload
1341
+ # Remove from current parent
1342
+ parent.removeChild(item)
1343
+ # Add to root
1344
+ self.tables_list.add_table_item(table_name, source, needs_reload)
1345
+ self.statusBar().showMessage(f'Moved table "{table_name}" to root')
1346
+ elif action and action.parent() == move_menu:
1347
+ # Move to selected folder
1348
+ target_folder = action.data()
1349
+ if target_folder:
1350
+ self.tables_list.move_item_to_folder(item, target_folder)
1351
+ self.statusBar().showMessage(f'Moved table "{table_name}" to folder "{target_folder.text(0)}"')
1352
+
1353
+ def reload_selected_table(self, table_name=None):
1354
+ """Reload the data for a table from its source file"""
1355
+ try:
1356
+ # If table_name is not provided, get it from the selected item
1357
+ if not table_name:
1358
+ current_item = self.tables_list.currentItem()
1359
+ if not current_item:
1360
+ return
1361
+ table_name = self.tables_list.get_table_name_from_item(current_item)
1362
+
1363
+ # Show a loading indicator
1364
+ self.statusBar().showMessage(f'Reloading table "{table_name}"...')
1365
+
1366
+ # Use the database manager to reload the table
1367
+ success, message = self.db_manager.reload_table(table_name)
1368
+
1369
+ if success:
1370
+ # Show success message
1371
+ self.statusBar().showMessage(message)
1372
+
1373
+ # Update completer with any new column names
1374
+ self.update_completer()
1375
+
1376
+ # Mark the table as reloaded (remove the reload icon)
1377
+ self.tables_list.mark_table_reloaded(table_name)
1378
+
1379
+ # Show a preview of the reloaded table
1380
+ table_item = self.tables_list.find_table_item(table_name)
1381
+ if table_item:
1382
+ self.show_table_preview(table_item)
1383
+ else:
1384
+ # Show error message
1385
+ QMessageBox.warning(self, "Reload Failed", message)
1386
+ self.statusBar().showMessage(f'Failed to reload table: {message}')
1387
+
1388
+ except Exception as e:
1389
+ QMessageBox.critical(self, "Error",
1390
+ f"Error reloading table:\n\n{str(e)}")
1391
+ self.statusBar().showMessage('Error reloading table')
1632
1392
 
1633
- def new_project(self):
1393
+ def new_project(self, skip_confirmation=False):
1634
1394
  """Create a new project by clearing current state"""
1635
- if self.conn:
1395
+ if self.db_manager.is_connected() and not skip_confirmation:
1636
1396
  reply = QMessageBox.question(self, 'New Project',
1637
- 'Are you sure you want to start a new project? All unsaved changes will be lost.',
1638
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1397
+ 'Are you sure you want to start a new project? All unsaved changes will be lost.',
1398
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1639
1399
  if reply == QMessageBox.StandardButton.Yes:
1640
1400
  # Close existing connection
1641
- if self.current_connection_type == "duckdb":
1642
- self.conn.close()
1643
- else: # sqlite
1644
- self.conn.close()
1401
+ self.db_manager.close_connection()
1402
+
1403
+ # Clear all database tracking
1404
+ self.db_manager.loaded_tables = {}
1405
+ self.db_manager.table_columns = {}
1645
1406
 
1646
1407
  # Reset state
1647
- self.conn = None
1648
- self.current_connection_type = None
1649
- self.loaded_tables.clear()
1650
- self.table_columns.clear()
1651
1408
  self.tables_list.clear()
1652
- self.query_edit.clear()
1653
- self.results_table.setRowCount(0)
1654
- self.results_table.setColumnCount(0)
1409
+
1410
+ # Clear all tabs except one
1411
+ while self.tab_widget.count() > 1:
1412
+ self.close_tab(1) # Always close tab at index 1 to keep at least one tab
1413
+
1414
+ # Clear the remaining tab
1415
+ first_tab = self.get_tab_at_index(0)
1416
+ if first_tab:
1417
+ first_tab.set_query_text("")
1418
+ first_tab.results_table.setRowCount(0)
1419
+ first_tab.results_table.setColumnCount(0)
1420
+ first_tab.row_count_label.setText("")
1421
+ first_tab.results_title.setText("RESULTS")
1422
+
1655
1423
  self.current_project_file = None
1656
1424
  self.setWindowTitle('SQL Shell')
1425
+ self.db_info_label.setText("No database connected")
1657
1426
  self.statusBar().showMessage('New project created')
1427
+ elif skip_confirmation:
1428
+ # Skip confirmation and just clear everything
1429
+ if self.db_manager.is_connected():
1430
+ self.db_manager.close_connection()
1431
+
1432
+ # Clear all database tracking
1433
+ self.db_manager.loaded_tables = {}
1434
+ self.db_manager.table_columns = {}
1435
+
1436
+ # Reset state
1437
+ self.tables_list.clear()
1438
+
1439
+ # Clear all tabs except one
1440
+ while self.tab_widget.count() > 1:
1441
+ self.close_tab(1) # Always close tab at index 1 to keep at least one tab
1442
+
1443
+ # Clear the remaining tab
1444
+ first_tab = self.get_tab_at_index(0)
1445
+ if first_tab:
1446
+ first_tab.set_query_text("")
1447
+ first_tab.results_table.setRowCount(0)
1448
+ first_tab.results_table.setColumnCount(0)
1449
+ first_tab.row_count_label.setText("")
1450
+ first_tab.results_title.setText("RESULTS")
1451
+
1452
+ self.current_project_file = None
1453
+ self.setWindowTitle('SQL Shell')
1454
+ self.db_info_label.setText("No database connected")
1658
1455
 
1659
1456
  def save_project(self):
1660
1457
  """Save the current project"""
@@ -1683,14 +1480,73 @@ LIMIT 10
1683
1480
  def save_project_to_file(self, file_name):
1684
1481
  """Save project data to a file"""
1685
1482
  try:
1483
+ # Save tab information
1484
+ tabs_data = []
1485
+ for i in range(self.tab_widget.count()):
1486
+ tab = self.tab_widget.widget(i)
1487
+ tab_data = {
1488
+ 'title': self.tab_widget.tabText(i),
1489
+ 'query': tab.get_query_text()
1490
+ }
1491
+ tabs_data.append(tab_data)
1492
+
1686
1493
  project_data = {
1687
1494
  'tables': {},
1688
- 'query': self.query_edit.toPlainText(),
1689
- 'connection_type': self.current_connection_type
1495
+ 'folders': {},
1496
+ 'tabs': tabs_data,
1497
+ 'connection_type': self.db_manager.connection_type,
1498
+ 'database_path': None # Initialize to None
1690
1499
  }
1691
1500
 
1692
- # Save table information
1693
- for table_name, file_path in self.loaded_tables.items():
1501
+ # If we have a database connection, save the path
1502
+ if self.db_manager.is_connected() and hasattr(self.db_manager, 'database_path'):
1503
+ project_data['database_path'] = self.db_manager.database_path
1504
+
1505
+ # Helper function to recursively save folder structure
1506
+ def save_folder_structure(parent_item, parent_path=""):
1507
+ if parent_item is None:
1508
+ # Handle top-level items
1509
+ for i in range(self.tables_list.topLevelItemCount()):
1510
+ item = self.tables_list.topLevelItem(i)
1511
+ if self.tables_list.is_folder_item(item):
1512
+ # It's a folder - add to folders and process its children
1513
+ folder_name = item.text(0)
1514
+ folder_id = f"folder_{i}"
1515
+ project_data['folders'][folder_id] = {
1516
+ 'name': folder_name,
1517
+ 'parent': None,
1518
+ 'expanded': item.isExpanded()
1519
+ }
1520
+ save_folder_structure(item, folder_id)
1521
+ else:
1522
+ # It's a table - add to tables at root level
1523
+ save_table_item(item)
1524
+ else:
1525
+ # Process children of this folder
1526
+ for i in range(parent_item.childCount()):
1527
+ child = parent_item.child(i)
1528
+ if self.tables_list.is_folder_item(child):
1529
+ # It's a subfolder
1530
+ folder_name = child.text(0)
1531
+ folder_id = f"{parent_path}_sub_{i}"
1532
+ project_data['folders'][folder_id] = {
1533
+ 'name': folder_name,
1534
+ 'parent': parent_path,
1535
+ 'expanded': child.isExpanded()
1536
+ }
1537
+ save_folder_structure(child, folder_id)
1538
+ else:
1539
+ # It's a table in this folder
1540
+ save_table_item(child, parent_path)
1541
+
1542
+ # Helper function to save table item
1543
+ def save_table_item(item, folder_id=None):
1544
+ table_name = self.tables_list.get_table_name_from_item(item)
1545
+ if not table_name or table_name not in self.db_manager.loaded_tables:
1546
+ return
1547
+
1548
+ file_path = self.db_manager.loaded_tables[table_name]
1549
+
1694
1550
  # For database tables and query results, store the special identifier
1695
1551
  if file_path in ['database', 'query_result']:
1696
1552
  source_path = file_path
@@ -1700,103 +1556,1687 @@ LIMIT 10
1700
1556
 
1701
1557
  project_data['tables'][table_name] = {
1702
1558
  'file_path': source_path,
1703
- 'columns': self.table_columns.get(table_name, [])
1559
+ 'columns': self.db_manager.table_columns.get(table_name, []),
1560
+ 'folder': folder_id
1704
1561
  }
1705
1562
 
1563
+ # Save the folder structure
1564
+ save_folder_structure(None)
1565
+
1706
1566
  with open(file_name, 'w') as f:
1707
1567
  json.dump(project_data, f, indent=4)
1708
1568
 
1569
+ # Add to recent projects
1570
+ self.add_recent_project(os.path.abspath(file_name))
1571
+
1709
1572
  self.statusBar().showMessage(f'Project saved to {file_name}')
1710
1573
 
1711
1574
  except Exception as e:
1712
1575
  QMessageBox.critical(self, "Error",
1713
1576
  f"Failed to save project:\n\n{str(e)}")
1714
1577
 
1715
- def open_project(self):
1578
+ def open_project(self, file_name=None):
1716
1579
  """Open a project file"""
1717
- file_name, _ = QFileDialog.getOpenFileName(
1718
- self,
1719
- "Open Project",
1720
- "",
1721
- "SQL Shell Project (*.sqls);;All Files (*)"
1722
- )
1580
+ if not file_name:
1581
+ # Check for unsaved changes before showing file dialog
1582
+ if self.has_unsaved_changes():
1583
+ reply = QMessageBox.question(self, 'Save Changes',
1584
+ 'Do you want to save your changes before opening another project?',
1585
+ QMessageBox.StandardButton.Save |
1586
+ QMessageBox.StandardButton.Discard |
1587
+ QMessageBox.StandardButton.Cancel)
1588
+
1589
+ if reply == QMessageBox.StandardButton.Save:
1590
+ self.save_project()
1591
+ elif reply == QMessageBox.StandardButton.Cancel:
1592
+ return
1593
+
1594
+ # Show file dialog after handling save prompt
1595
+ file_name, _ = QFileDialog.getOpenFileName(
1596
+ self,
1597
+ "Open Project",
1598
+ "",
1599
+ "SQL Shell Project (*.sqls);;All Files (*)"
1600
+ )
1723
1601
 
1724
1602
  if file_name:
1725
1603
  try:
1604
+ # Create a progress dialog to keep UI responsive
1605
+ progress = QProgressDialog("Loading project...", "Cancel", 0, 100, self)
1606
+ progress.setWindowTitle("Opening Project")
1607
+ progress.setWindowModality(Qt.WindowModality.WindowModal)
1608
+ progress.setMinimumDuration(500) # Show after 500ms delay
1609
+ progress.setValue(0)
1610
+
1611
+ # Load project data
1726
1612
  with open(file_name, 'r') as f:
1727
1613
  project_data = json.load(f)
1728
1614
 
1615
+ # Update progress
1616
+ progress.setValue(10)
1617
+ QApplication.processEvents()
1618
+
1729
1619
  # Start fresh
1730
- self.new_project()
1620
+ self.new_project(skip_confirmation=True)
1621
+ progress.setValue(15)
1622
+ QApplication.processEvents()
1623
+
1624
+ # Make sure all database tables are cleared from tracking
1625
+ self.db_manager.loaded_tables = {}
1626
+ self.db_manager.table_columns = {}
1731
1627
 
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")
1628
+ # Check if there's a database path in the project
1629
+ has_database_path = 'database_path' in project_data and project_data['database_path']
1630
+ has_database_tables = any(table_info.get('file_path') == 'database'
1631
+ for table_info in project_data.get('tables', {}).values())
1632
+
1633
+ # Connect to database if needed
1634
+ progress.setLabelText("Connecting to database...")
1635
+ database_tables_loaded = False
1636
+ database_connection_message = None
1637
+
1638
+ if has_database_path and has_database_tables:
1639
+ database_path = project_data['database_path']
1640
+ try:
1641
+ if os.path.exists(database_path):
1642
+ # Connect to the database
1643
+ self.db_manager.open_database(database_path, load_all_tables=False)
1644
+ self.db_info_label.setText(self.db_manager.get_connection_info())
1645
+ self.statusBar().showMessage(f"Connected to database: {database_path}")
1646
+
1647
+ # Mark database tables as loaded
1648
+ database_tables_loaded = True
1649
+ else:
1650
+ database_tables_loaded = False
1651
+ # Store the message instead of showing immediately
1652
+ database_connection_message = (
1653
+ "Database Not Found",
1654
+ f"The project's database file was not found at:\n{database_path}\n\n"
1655
+ "Database tables will be shown but not accessible until you reconnect to the database.\n\n"
1656
+ "Use the 'Open Database' button to connect to your database file."
1657
+ )
1658
+ except Exception as e:
1659
+ database_tables_loaded = False
1660
+ # Store the message instead of showing immediately
1661
+ database_connection_message = (
1662
+ "Database Connection Error",
1663
+ f"Failed to connect to the project's database:\n{str(e)}\n\n"
1664
+ "Database tables will be shown but not accessible until you reconnect to the database.\n\n"
1665
+ "Use the 'Open Database' button to connect to your database file."
1666
+ )
1667
+ else:
1668
+ # Create connection if needed (we don't have a specific database to connect to)
1669
+ database_tables_loaded = False
1670
+ if not self.db_manager.is_connected():
1671
+ connection_info = self.db_manager.create_memory_connection()
1672
+ self.db_info_label.setText(connection_info)
1673
+ elif 'connection_type' in project_data and project_data['connection_type'] != self.db_manager.connection_type:
1674
+ # If connected but with a different database type than what was saved in the project
1675
+ # Store the message instead of showing immediately
1676
+ database_connection_message = (
1677
+ "Database Type Mismatch",
1678
+ f"The project was saved with a {project_data['connection_type']} database, but you're currently using {self.db_manager.connection_type}.\n\n"
1679
+ "Some database-specific features may not work correctly. Consider reconnecting to the correct database type."
1680
+ )
1681
+
1682
+ progress.setValue(20)
1683
+ QApplication.processEvents()
1684
+
1685
+ # First, recreate the folder structure
1686
+ folder_items = {} # Store folder items by ID
1687
+
1688
+ # Create folders first
1689
+ if 'folders' in project_data:
1690
+ progress.setLabelText("Creating folders...")
1691
+ # First pass: create top-level folders
1692
+ for folder_id, folder_info in project_data['folders'].items():
1693
+ if folder_info.get('parent') is None:
1694
+ # Create top-level folder
1695
+ folder = self.tables_list.create_folder(folder_info['name'])
1696
+ folder_items[folder_id] = folder
1697
+ # Set expanded state
1698
+ folder.setExpanded(folder_info.get('expanded', True))
1699
+
1700
+ # Second pass: create subfolders
1701
+ for folder_id, folder_info in project_data['folders'].items():
1702
+ parent_id = folder_info.get('parent')
1703
+ if parent_id is not None and parent_id in folder_items:
1704
+ # Create subfolder under parent
1705
+ parent_folder = folder_items[parent_id]
1706
+ subfolder = QTreeWidgetItem(parent_folder)
1707
+ subfolder.setText(0, folder_info['name'])
1708
+ subfolder.setIcon(0, QIcon.fromTheme("folder"))
1709
+ subfolder.setData(0, Qt.ItemDataRole.UserRole, "folder")
1710
+ # Make folder text bold
1711
+ font = subfolder.font(0)
1712
+ font.setBold(True)
1713
+ subfolder.setFont(0, font)
1714
+ # Set folder flags
1715
+ subfolder.setFlags(subfolder.flags() | Qt.ItemFlag.ItemIsDropEnabled)
1716
+ # Set expanded state
1717
+ subfolder.setExpanded(folder_info.get('expanded', True))
1718
+ folder_items[folder_id] = subfolder
1719
+
1720
+ progress.setValue(25)
1721
+ QApplication.processEvents()
1722
+
1723
+ # Calculate progress steps for loading tables
1724
+ table_count = len(project_data.get('tables', {}))
1725
+ table_progress_start = 30
1726
+ table_progress_end = 70
1727
+ table_progress_step = (table_progress_end - table_progress_start) / max(1, table_count)
1728
+ current_progress = table_progress_start
1737
1729
 
1738
1730
  # Load tables
1739
- for table_name, table_info in project_data['tables'].items():
1731
+ for table_name, table_info in project_data.get('tables', {}).items():
1732
+ if progress.wasCanceled():
1733
+ break
1734
+
1735
+ progress.setLabelText(f"Processing table: {table_name}")
1740
1736
  file_path = table_info['file_path']
1737
+ self.statusBar().showMessage(f"Processing table: {table_name} from {file_path}")
1738
+
1741
1739
  try:
1740
+ # Determine folder placement
1741
+ folder_id = table_info.get('folder')
1742
+ parent_folder = folder_items.get(folder_id) if folder_id else None
1743
+
1742
1744
  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'
1749
- self.tables_list.addItem(f"{table_name} (database)")
1745
+ # Different handling based on whether database connection is active
1746
+ if database_tables_loaded:
1747
+ # Store table info without loading data
1748
+ self.db_manager.loaded_tables[table_name] = 'database'
1749
+ if 'columns' in table_info:
1750
+ self.db_manager.table_columns[table_name] = table_info['columns']
1751
+
1752
+ # Create item without reload icon
1753
+ if parent_folder:
1754
+ # Add to folder
1755
+ item = QTreeWidgetItem(parent_folder)
1756
+ item.setText(0, f"{table_name} (database)")
1757
+ item.setIcon(0, QIcon.fromTheme("x-office-spreadsheet"))
1758
+ item.setData(0, Qt.ItemDataRole.UserRole, "table")
1759
+ else:
1760
+ # Add to root
1761
+ self.tables_list.add_table_item(table_name, "database", needs_reload=False)
1762
+ else:
1763
+ # No active database connection, just register the table name
1764
+ self.db_manager.loaded_tables[table_name] = 'database'
1765
+ if 'columns' in table_info:
1766
+ self.db_manager.table_columns[table_name] = table_info['columns']
1767
+
1768
+ # Create item with reload icon
1769
+ if parent_folder:
1770
+ # Add to folder
1771
+ item = QTreeWidgetItem(parent_folder)
1772
+ item.setText(0, f"{table_name} (database)")
1773
+ item.setIcon(0, QIcon.fromTheme("view-refresh"))
1774
+ item.setData(0, Qt.ItemDataRole.UserRole, "table")
1775
+ item.setToolTip(0, f"Table '{table_name}' needs to be loaded (double-click or use context menu)")
1776
+ self.tables_list.tables_needing_reload.add(table_name)
1777
+ else:
1778
+ # Add to root
1779
+ self.tables_list.add_table_item(table_name, "database", needs_reload=True)
1750
1780
  elif file_path == 'query_result':
1751
- # For tables from query results, we'll need to re-run the query
1752
- # For now, just note it as a query result table
1753
- self.loaded_tables[table_name] = 'query_result'
1754
- self.tables_list.addItem(f"{table_name} (query result)")
1781
+ # For tables from query results, just note it as a query result table
1782
+ self.db_manager.loaded_tables[table_name] = 'query_result'
1783
+
1784
+ # Create item with reload icon
1785
+ if parent_folder:
1786
+ # Add to folder
1787
+ item = QTreeWidgetItem(parent_folder)
1788
+ item.setText(0, f"{table_name} (query result)")
1789
+ item.setIcon(0, QIcon.fromTheme("view-refresh"))
1790
+ item.setData(0, Qt.ItemDataRole.UserRole, "table")
1791
+ item.setToolTip(0, f"Table '{table_name}' needs to be loaded (double-click or use context menu)")
1792
+ self.tables_list.tables_needing_reload.add(table_name)
1793
+ else:
1794
+ # Add to root
1795
+ self.tables_list.add_table_item(table_name, "query result", needs_reload=True)
1755
1796
  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)
1797
+ # Register the file as a table source but don't load data yet
1798
+ self.db_manager.loaded_tables[table_name] = file_path
1799
+ if 'columns' in table_info:
1800
+ self.db_manager.table_columns[table_name] = table_info['columns']
1801
+
1802
+ # Create item with reload icon
1803
+ if parent_folder:
1804
+ # Add to folder
1805
+ item = QTreeWidgetItem(parent_folder)
1806
+ item.setText(0, f"{table_name} ({os.path.basename(file_path)})")
1807
+ item.setIcon(0, QIcon.fromTheme("view-refresh"))
1808
+ item.setData(0, Qt.ItemDataRole.UserRole, "table")
1809
+ item.setToolTip(0, f"Table '{table_name}' needs to be loaded (double-click or use context menu)")
1810
+ self.tables_list.tables_needing_reload.add(table_name)
1763
1811
  else:
1764
- 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)})")
1812
+ # Add to root
1813
+ self.tables_list.add_table_item(table_name, os.path.basename(file_path), needs_reload=True)
1770
1814
  else:
1771
- QMessageBox.warning(self, "Warning",
1772
- f"Could not find file for table {table_name}: {file_path}")
1773
- continue
1774
-
1775
- # Store the columns
1776
- self.table_columns[table_name] = table_info['columns']
1815
+ # File doesn't exist, but add to list with warning
1816
+ self.db_manager.loaded_tables[table_name] = file_path
1817
+ if 'columns' in table_info:
1818
+ self.db_manager.table_columns[table_name] = table_info['columns']
1819
+
1820
+ # Create item with reload icon and missing warning
1821
+ if parent_folder:
1822
+ # Add to folder
1823
+ item = QTreeWidgetItem(parent_folder)
1824
+ item.setText(0, f"{table_name} ({os.path.basename(file_path)} (missing))")
1825
+ item.setIcon(0, QIcon.fromTheme("view-refresh"))
1826
+ item.setData(0, Qt.ItemDataRole.UserRole, "table")
1827
+ item.setToolTip(0, f"Table '{table_name}' needs to be loaded (double-click or use context menu)")
1828
+ self.tables_list.tables_needing_reload.add(table_name)
1829
+ else:
1830
+ # Add to root
1831
+ self.tables_list.add_table_item(table_name, f"{os.path.basename(file_path)} (missing)", needs_reload=True)
1777
1832
 
1778
1833
  except Exception as e:
1779
1834
  QMessageBox.warning(self, "Warning",
1780
- f"Failed to load table {table_name}:\n{str(e)}")
1835
+ f"Failed to process table {table_name}:\n{str(e)}")
1836
+
1837
+ # Update progress for this table
1838
+ current_progress += table_progress_step
1839
+ progress.setValue(int(current_progress))
1840
+ QApplication.processEvents() # Keep UI responsive
1841
+
1842
+ # Check if the operation was canceled
1843
+ if progress.wasCanceled():
1844
+ self.statusBar().showMessage("Project loading was canceled")
1845
+ progress.close()
1846
+ return
1847
+
1848
+ progress.setValue(75)
1849
+ progress.setLabelText("Setting up tabs...")
1850
+ QApplication.processEvents()
1851
+
1852
+ # Load tabs in a more efficient way
1853
+ if 'tabs' in project_data and project_data['tabs']:
1854
+ try:
1855
+ # Temporarily disable signals
1856
+ self.tab_widget.blockSignals(True)
1857
+
1858
+ # First, pre-remove any existing tabs
1859
+ while self.tab_widget.count() > 0:
1860
+ widget = self.tab_widget.widget(0)
1861
+ self.tab_widget.removeTab(0)
1862
+ if widget in self.tabs:
1863
+ self.tabs.remove(widget)
1864
+ widget.deleteLater()
1865
+
1866
+ # Then create all tab widgets at once (empty)
1867
+ tab_count = len(project_data['tabs'])
1868
+ tab_progress_step = 15 / max(1, tab_count)
1869
+ progress.setValue(80)
1870
+ QApplication.processEvents()
1871
+
1872
+ # Create all tab widgets first without setting content
1873
+ for i, tab_data in enumerate(project_data['tabs']):
1874
+ # Create a new tab
1875
+ tab = QueryTab(self)
1876
+ self.tabs.append(tab)
1877
+
1878
+ # Add to tab widget
1879
+ title = tab_data.get('title', f'Query {i+1}')
1880
+ self.tab_widget.addTab(tab, title)
1881
+
1882
+ progress.setValue(int(80 + i * tab_progress_step/2))
1883
+ QApplication.processEvents()
1884
+
1885
+ # Now set the content for each tab
1886
+ for i, tab_data in enumerate(project_data['tabs']):
1887
+ # Get the tab and set its query text
1888
+ tab = self.tab_widget.widget(i)
1889
+ if tab and 'query' in tab_data:
1890
+ tab.set_query_text(tab_data['query'])
1891
+
1892
+ progress.setValue(int(87 + i * tab_progress_step/2))
1893
+ QApplication.processEvents()
1894
+
1895
+ # Re-enable signals
1896
+ self.tab_widget.blockSignals(False)
1897
+
1898
+ # Set current tab
1899
+ if self.tab_widget.count() > 0:
1900
+ self.tab_widget.setCurrentIndex(0)
1901
+
1902
+ except Exception as e:
1903
+ # If there's an error, ensure we restore signals
1904
+ self.tab_widget.blockSignals(False)
1905
+ self.statusBar().showMessage(f"Error loading tabs: {str(e)}")
1906
+ # Create a single default tab if all fails
1907
+ if self.tab_widget.count() == 0:
1908
+ self.add_tab()
1909
+ else:
1910
+ # Create default tab if no tabs in project
1911
+ self.add_tab()
1781
1912
 
1782
- # Restore query
1783
- if 'query' in project_data:
1784
- self.query_edit.setPlainText(project_data['query'])
1913
+ progress.setValue(90)
1914
+ progress.setLabelText("Finishing up...")
1915
+ QApplication.processEvents()
1785
1916
 
1786
1917
  # Update UI
1787
1918
  self.current_project_file = file_name
1788
1919
  self.setWindowTitle(f'SQL Shell - {os.path.basename(file_name)}')
1789
- self.statusBar().showMessage(f'Project loaded from {file_name}')
1790
- self.update_completer()
1920
+
1921
+ # Add to recent projects
1922
+ self.add_recent_project(os.path.abspath(file_name))
1923
+
1924
+ # Defer the auto-completer update to after loading is complete
1925
+ # This helps prevent UI freezing during project loading
1926
+ progress.setValue(95)
1927
+ QApplication.processEvents()
1928
+
1929
+ # Use a timer to update the completer after the UI is responsive
1930
+ complete_timer = QTimer()
1931
+ complete_timer.setSingleShot(True)
1932
+ complete_timer.timeout.connect(self.update_completer)
1933
+ complete_timer.start(100) # Short delay before updating completer
1934
+
1935
+ # Queue another update for reliability - sometimes the first update might not fully complete
1936
+ failsafe_timer = QTimer()
1937
+ failsafe_timer.setSingleShot(True)
1938
+ failsafe_timer.timeout.connect(self.update_completer)
1939
+ failsafe_timer.start(2000) # Try again after 2 seconds to ensure completion is loaded
1940
+
1941
+ progress.setValue(100)
1942
+ QApplication.processEvents()
1943
+
1944
+ # Show message about tables needing reload
1945
+ reload_count = len(self.tables_list.tables_needing_reload)
1946
+ if reload_count > 0:
1947
+ self.statusBar().showMessage(
1948
+ f'Project loaded from {file_name} with {table_count} tables. {reload_count} tables need to be reloaded (click reload icon).'
1949
+ )
1950
+ else:
1951
+ self.statusBar().showMessage(
1952
+ f'Project loaded from {file_name} with {table_count} tables.'
1953
+ )
1954
+
1955
+ # Close progress dialog before showing message boxes
1956
+ progress.close()
1957
+
1958
+ # Now show any database connection message we stored earlier
1959
+ if database_connection_message and not database_tables_loaded and has_database_tables:
1960
+ title, message = database_connection_message
1961
+ QMessageBox.warning(self, title, message)
1791
1962
 
1792
1963
  except Exception as e:
1793
1964
  QMessageBox.critical(self, "Error",
1794
1965
  f"Failed to open project:\n\n{str(e)}")
1795
1966
 
1967
+ def rename_table(self, old_name, new_name):
1968
+ """Rename a table in the database and update tracking"""
1969
+ try:
1970
+ # Use the database manager to rename the table
1971
+ result = self.db_manager.rename_table(old_name, new_name)
1972
+
1973
+ if result:
1974
+ # Update completer
1975
+ self.update_completer()
1976
+ return True
1977
+
1978
+ return False
1979
+
1980
+ except Exception as e:
1981
+ QMessageBox.critical(self, "Error", f"Failed to rename table:\n\n{str(e)}")
1982
+ return False
1983
+
1984
+ def load_recent_projects(self):
1985
+ """Load recent projects from settings file"""
1986
+ try:
1987
+ settings_file = os.path.join(os.path.expanduser('~'), '.sqlshell_settings.json')
1988
+ if os.path.exists(settings_file):
1989
+ with open(settings_file, 'r') as f:
1990
+ settings = json.load(f)
1991
+ self.recent_projects = settings.get('recent_projects', [])
1992
+
1993
+ # Load user preferences
1994
+ preferences = settings.get('preferences', {})
1995
+ self.auto_load_recent_project = preferences.get('auto_load_recent_project', True)
1996
+
1997
+ # Load window settings if available
1998
+ window_settings = settings.get('window', {})
1999
+ if window_settings:
2000
+ self.restore_window_state(window_settings)
2001
+ except Exception:
2002
+ self.recent_projects = []
2003
+
2004
+ def save_recent_projects(self):
2005
+ """Save recent projects to settings file"""
2006
+ try:
2007
+ settings_file = os.path.join(os.path.expanduser('~'), '.sqlshell_settings.json')
2008
+ settings = {}
2009
+ if os.path.exists(settings_file):
2010
+ with open(settings_file, 'r') as f:
2011
+ settings = json.load(f)
2012
+ settings['recent_projects'] = self.recent_projects
2013
+
2014
+ # Save user preferences
2015
+ if 'preferences' not in settings:
2016
+ settings['preferences'] = {}
2017
+ settings['preferences']['auto_load_recent_project'] = self.auto_load_recent_project
2018
+
2019
+ # Save window settings
2020
+ window_settings = self.save_window_state()
2021
+ settings['window'] = window_settings
2022
+
2023
+ # Also save recent and frequent files data
2024
+ settings['recent_files'] = self.recent_files
2025
+ settings['frequent_files'] = self.frequent_files
2026
+
2027
+ with open(settings_file, 'w') as f:
2028
+ json.dump(settings, f, indent=4)
2029
+ except Exception as e:
2030
+ print(f"Error saving recent projects: {e}")
2031
+
2032
+ def save_window_state(self):
2033
+ """Save current window state"""
2034
+ window_settings = {
2035
+ 'maximized': self.isMaximized(),
2036
+ 'geometry': {
2037
+ 'x': self.geometry().x(),
2038
+ 'y': self.geometry().y(),
2039
+ 'width': self.geometry().width(),
2040
+ 'height': self.geometry().height()
2041
+ }
2042
+ }
2043
+ return window_settings
2044
+
2045
+ def restore_window_state(self, window_settings):
2046
+ """Restore window state from settings"""
2047
+ try:
2048
+ # Check if we have valid geometry settings
2049
+ geometry = window_settings.get('geometry', {})
2050
+ if all(key in geometry for key in ['x', 'y', 'width', 'height']):
2051
+ x, y = geometry['x'], geometry['y']
2052
+ width, height = geometry['width'], geometry['height']
2053
+
2054
+ # Ensure the window is visible on the current screen
2055
+ screen = QApplication.primaryScreen()
2056
+ screen_geometry = screen.availableGeometry()
2057
+
2058
+ # Adjust if window would be off-screen
2059
+ if x < 0 or x + 100 > screen_geometry.width():
2060
+ x = 100
2061
+ if y < 0 or y + 100 > screen_geometry.height():
2062
+ y = 100
2063
+
2064
+ # Adjust if window is too large for the current screen
2065
+ if width > screen_geometry.width():
2066
+ width = int(screen_geometry.width() * 0.85)
2067
+ if height > screen_geometry.height():
2068
+ height = int(screen_geometry.height() * 0.85)
2069
+
2070
+ self.setGeometry(x, y, width, height)
2071
+
2072
+ # Set maximized state if needed
2073
+ if window_settings.get('maximized', False):
2074
+ self.showMaximized()
2075
+ self.was_maximized = True
2076
+
2077
+ except Exception as e:
2078
+ print(f"Error restoring window state: {e}")
2079
+ # Fall back to default geometry
2080
+ screen = QApplication.primaryScreen()
2081
+ screen_geometry = screen.availableGeometry()
2082
+ self.setGeometry(100, 100,
2083
+ min(1400, int(screen_geometry.width() * 0.85)),
2084
+ min(800, int(screen_geometry.height() * 0.85)))
2085
+
2086
+ def add_recent_project(self, project_path):
2087
+ """Add a project to recent projects list"""
2088
+ if project_path in self.recent_projects:
2089
+ self.recent_projects.remove(project_path)
2090
+ self.recent_projects.insert(0, project_path)
2091
+ self.recent_projects = self.recent_projects[:self.max_recent_projects]
2092
+ self.save_recent_projects()
2093
+ self.update_recent_projects_menu()
2094
+
2095
+ def update_recent_projects_menu(self):
2096
+ """Update the recent projects menu"""
2097
+ self.recent_projects_menu.clear()
2098
+
2099
+ if not self.recent_projects:
2100
+ no_recent = self.recent_projects_menu.addAction("No Recent Projects")
2101
+ no_recent.setEnabled(False)
2102
+ return
2103
+
2104
+ for project_path in self.recent_projects:
2105
+ if os.path.exists(project_path):
2106
+ action = self.recent_projects_menu.addAction(os.path.basename(project_path))
2107
+ action.setData(project_path)
2108
+ action.triggered.connect(lambda checked, path=project_path: self.open_recent_project(path))
2109
+
2110
+ if self.recent_projects:
2111
+ self.recent_projects_menu.addSeparator()
2112
+ clear_action = self.recent_projects_menu.addAction("Clear Recent Projects")
2113
+ clear_action.triggered.connect(self.clear_recent_projects)
2114
+
2115
+ def open_recent_project(self, project_path):
2116
+ """Open a project from the recent projects list"""
2117
+ if os.path.exists(project_path):
2118
+ # Check if current project has unsaved changes before loading the new one
2119
+ if self.has_unsaved_changes():
2120
+ reply = QMessageBox.question(self, 'Save Changes',
2121
+ 'Do you want to save your changes before loading another project?',
2122
+ QMessageBox.StandardButton.Save |
2123
+ QMessageBox.StandardButton.Discard |
2124
+ QMessageBox.StandardButton.Cancel)
2125
+
2126
+ if reply == QMessageBox.StandardButton.Save:
2127
+ self.save_project()
2128
+ elif reply == QMessageBox.StandardButton.Cancel:
2129
+ return
2130
+
2131
+ # Now proceed with loading the project
2132
+ self.current_project_file = project_path
2133
+ self.open_project(project_path)
2134
+ else:
2135
+ QMessageBox.warning(self, "Warning",
2136
+ f"Project file not found:\n{project_path}")
2137
+ self.recent_projects.remove(project_path)
2138
+ self.save_recent_projects()
2139
+ self.update_recent_projects_menu()
2140
+
2141
+ def clear_recent_projects(self):
2142
+ """Clear the list of recent projects"""
2143
+ self.recent_projects.clear()
2144
+ self.save_recent_projects()
2145
+ self.update_recent_projects_menu()
2146
+
2147
+ def load_recent_files(self):
2148
+ """Load recent and frequent files from settings file"""
2149
+ try:
2150
+ settings_file = os.path.join(os.path.expanduser('~'), '.sqlshell_settings.json')
2151
+ if os.path.exists(settings_file):
2152
+ with open(settings_file, 'r') as f:
2153
+ settings = json.load(f)
2154
+ self.recent_files = settings.get('recent_files', [])
2155
+ self.frequent_files = settings.get('frequent_files', {})
2156
+ except Exception:
2157
+ self.recent_files = []
2158
+ self.frequent_files = {}
2159
+
2160
+ def save_recent_files(self):
2161
+ """Save recent and frequent files to settings file"""
2162
+ try:
2163
+ settings_file = os.path.join(os.path.expanduser('~'), '.sqlshell_settings.json')
2164
+ settings = {}
2165
+ if os.path.exists(settings_file):
2166
+ with open(settings_file, 'r') as f:
2167
+ settings = json.load(f)
2168
+ settings['recent_files'] = self.recent_files
2169
+ settings['frequent_files'] = self.frequent_files
2170
+
2171
+ with open(settings_file, 'w') as f:
2172
+ json.dump(settings, f, indent=4)
2173
+ except Exception as e:
2174
+ print(f"Error saving recent files: {e}")
2175
+
2176
+ def add_recent_file(self, file_path):
2177
+ """Add a file to recent files list and update frequent files count"""
2178
+ file_path = os.path.abspath(file_path)
2179
+
2180
+ # Update recent files
2181
+ if file_path in self.recent_files:
2182
+ self.recent_files.remove(file_path)
2183
+ self.recent_files.insert(0, file_path)
2184
+ self.recent_files = self.recent_files[:self.max_recent_files]
2185
+
2186
+ # Update frequency count
2187
+ if file_path in self.frequent_files:
2188
+ self.frequent_files[file_path] += 1
2189
+ else:
2190
+ self.frequent_files[file_path] = 1
2191
+
2192
+ # Save to settings
2193
+ self.save_recent_files()
2194
+
2195
+ # Update the quick access menu if it exists
2196
+ if hasattr(self, 'quick_access_menu'):
2197
+ self.update_quick_access_menu()
2198
+
2199
+ def get_frequent_files(self, limit=10):
2200
+ """Get the most frequently used files"""
2201
+ sorted_files = sorted(
2202
+ self.frequent_files.items(),
2203
+ key=lambda item: item[1],
2204
+ reverse=True
2205
+ )
2206
+ return [path for path, count in sorted_files[:limit] if os.path.exists(path)]
2207
+
2208
+ def clear_recent_files(self):
2209
+ """Clear the list of recent files"""
2210
+ self.recent_files.clear()
2211
+ self.save_recent_files()
2212
+ if hasattr(self, 'quick_access_menu'):
2213
+ self.update_quick_access_menu()
2214
+
2215
+ def clear_frequent_files(self):
2216
+ """Clear the list of frequent files"""
2217
+ self.frequent_files.clear()
2218
+ self.save_recent_files()
2219
+ if hasattr(self, 'quick_access_menu'):
2220
+ self.update_quick_access_menu()
2221
+
2222
+ def update_quick_access_menu(self):
2223
+ """Update the quick access menu with recent and frequent files"""
2224
+ if not hasattr(self, 'quick_access_menu'):
2225
+ return
2226
+
2227
+ self.quick_access_menu.clear()
2228
+
2229
+ # Add "Recent Files" section
2230
+ if self.recent_files:
2231
+ recent_section = self.quick_access_menu.addSection("Recent Files")
2232
+
2233
+ for file_path in self.recent_files[:10]: # Show top 10 recent files
2234
+ if os.path.exists(file_path):
2235
+ file_name = os.path.basename(file_path)
2236
+ action = self.quick_access_menu.addAction(file_name)
2237
+ action.setData(file_path)
2238
+ action.setToolTip(file_path)
2239
+ action.triggered.connect(lambda checked, path=file_path: self.quick_open_file(path))
2240
+
2241
+ # Add "Frequently Used Files" section
2242
+ frequent_files = self.get_frequent_files(10) # Get top 10 frequent files
2243
+ if frequent_files:
2244
+ self.quick_access_menu.addSeparator()
2245
+ freq_section = self.quick_access_menu.addSection("Frequently Used Files")
2246
+
2247
+ for file_path in frequent_files:
2248
+ file_name = os.path.basename(file_path)
2249
+ count = self.frequent_files.get(file_path, 0)
2250
+ action = self.quick_access_menu.addAction(f"{file_name} ({count} uses)")
2251
+ action.setData(file_path)
2252
+ action.setToolTip(file_path)
2253
+ action.triggered.connect(lambda checked, path=file_path: self.quick_open_file(path))
2254
+
2255
+ # Add management options if we have any files
2256
+ if self.recent_files or self.frequent_files:
2257
+ self.quick_access_menu.addSeparator()
2258
+ clear_recent = self.quick_access_menu.addAction("Clear Recent Files")
2259
+ clear_recent.triggered.connect(self.clear_recent_files)
2260
+
2261
+ clear_frequent = self.quick_access_menu.addAction("Clear Frequent Files")
2262
+ clear_frequent.triggered.connect(self.clear_frequent_files)
2263
+ else:
2264
+ # No files placeholder
2265
+ no_files = self.quick_access_menu.addAction("No Recent Files")
2266
+ no_files.setEnabled(False)
2267
+
2268
+ def quick_open_file(self, file_path):
2269
+ """Open a file from the quick access menu"""
2270
+ if not os.path.exists(file_path):
2271
+ QMessageBox.warning(self, "File Not Found",
2272
+ f"The file no longer exists:\n{file_path}")
2273
+
2274
+ # Remove from tracking
2275
+ if file_path in self.recent_files:
2276
+ self.recent_files.remove(file_path)
2277
+ if file_path in self.frequent_files:
2278
+ del self.frequent_files[file_path]
2279
+ self.save_recent_files()
2280
+ self.update_quick_access_menu()
2281
+ return
2282
+
2283
+ try:
2284
+ # Determine file type
2285
+ file_ext = os.path.splitext(file_path)[1].lower()
2286
+
2287
+ # Check if this is a Delta table directory
2288
+ is_delta_table = False
2289
+ if os.path.isdir(file_path):
2290
+ delta_path = Path(file_path)
2291
+ delta_log_path = delta_path / '_delta_log'
2292
+ if delta_log_path.exists():
2293
+ is_delta_table = True
2294
+
2295
+ if is_delta_table:
2296
+ # Delta table directory
2297
+ if not self.db_manager.is_connected():
2298
+ # Create a default in-memory DuckDB connection if none exists
2299
+ connection_info = self.db_manager.create_memory_connection()
2300
+ self.db_info_label.setText(connection_info)
2301
+
2302
+ # Use the database manager to load the Delta table
2303
+ table_name, df = self.db_manager.load_file(file_path)
2304
+
2305
+ # Update UI using new method
2306
+ self.tables_list.add_table_item(table_name, os.path.basename(file_path))
2307
+ self.statusBar().showMessage(f'Loaded Delta table from {file_path} as "{table_name}"')
2308
+
2309
+ # Show preview of loaded data
2310
+ preview_df = df.head()
2311
+ current_tab = self.get_current_tab()
2312
+ if current_tab:
2313
+ self.populate_table(preview_df)
2314
+ current_tab.results_title.setText(f"PREVIEW: {table_name}")
2315
+
2316
+ # Update completer with new table and column names
2317
+ self.update_completer()
2318
+ elif file_ext in ['.db', '.sqlite', '.sqlite3']:
2319
+ # Database file
2320
+ # Clear existing database tables from the list widget
2321
+ for i in range(self.tables_list.topLevelItemCount() - 1, -1, -1):
2322
+ item = self.tables_list.topLevelItem(i)
2323
+ if item and item.text(0).endswith('(database)'):
2324
+ self.tables_list.takeTopLevelItem(i)
2325
+
2326
+ # Use the database manager to open the database
2327
+ self.db_manager.open_database(file_path)
2328
+
2329
+ # Update UI with tables from the database using new method
2330
+ for table_name, source in self.db_manager.loaded_tables.items():
2331
+ if source == 'database':
2332
+ self.tables_list.add_table_item(table_name, "database")
2333
+
2334
+ # Update the completer with table and column names
2335
+ self.update_completer()
2336
+
2337
+ # Update status bar
2338
+ self.statusBar().showMessage(f"Connected to database: {file_path}")
2339
+ self.db_info_label.setText(self.db_manager.get_connection_info())
2340
+
2341
+ elif file_ext in ['.xlsx', '.xls', '.csv', '.parquet']:
2342
+ # Data file
2343
+ if not self.db_manager.is_connected():
2344
+ # Create a default in-memory DuckDB connection if none exists
2345
+ connection_info = self.db_manager.create_memory_connection()
2346
+ self.db_info_label.setText(connection_info)
2347
+
2348
+ # Use the database manager to load the file
2349
+ table_name, df = self.db_manager.load_file(file_path)
2350
+
2351
+ # Update UI using new method
2352
+ self.tables_list.add_table_item(table_name, os.path.basename(file_path))
2353
+ self.statusBar().showMessage(f'Loaded {file_path} as table "{table_name}"')
2354
+
2355
+ # Show preview of loaded data
2356
+ preview_df = df.head()
2357
+ current_tab = self.get_current_tab()
2358
+ if current_tab:
2359
+ self.populate_table(preview_df)
2360
+ current_tab.results_title.setText(f"PREVIEW: {table_name}")
2361
+
2362
+ # Update completer with new table and column names
2363
+ self.update_completer()
2364
+ else:
2365
+ QMessageBox.warning(self, "Unsupported File Type",
2366
+ f"The file type {file_ext} is not supported.")
2367
+ return
2368
+
2369
+ # Update tracking - increment usage count
2370
+ self.add_recent_file(file_path)
2371
+
2372
+ except Exception as e:
2373
+ QMessageBox.critical(self, "Error",
2374
+ f"Failed to open file:\n\n{str(e)}")
2375
+ self.statusBar().showMessage(f"Error opening file: {os.path.basename(file_path)}")
2376
+
2377
+ def show_quick_access_menu(self):
2378
+ """Display the quick access menu when the button is clicked"""
2379
+ # First, make sure the menu is up to date
2380
+ self.update_quick_access_menu()
2381
+
2382
+ # Show the menu below the quick access button
2383
+ if hasattr(self, 'quick_access_menu') and hasattr(self, 'quick_access_btn'):
2384
+ self.quick_access_menu.popup(self.quick_access_btn.mapToGlobal(
2385
+ QPoint(0, self.quick_access_btn.height())))
2386
+
2387
+ def add_tab(self, title="Query 1"):
2388
+ """Add a new query tab"""
2389
+ # Ensure title is a string
2390
+ title = str(title)
2391
+
2392
+ # Create a new tab with a unique name if needed
2393
+ if title == "Query 1" and self.tab_widget.count() > 0:
2394
+ # Generate a unique tab name (Query 2, Query 3, etc.)
2395
+ # Use a more efficient approach to find a unique name
2396
+ base_name = "Query"
2397
+ existing_names = set()
2398
+
2399
+ # Collect existing tab names first (more efficient than checking each time)
2400
+ for i in range(self.tab_widget.count()):
2401
+ existing_names.add(self.tab_widget.tabText(i))
2402
+
2403
+ # Find the next available number
2404
+ counter = 1
2405
+ while f"{base_name} {counter}" in existing_names:
2406
+ counter += 1
2407
+ title = f"{base_name} {counter}"
2408
+
2409
+ # Create the tab content
2410
+ tab = QueryTab(self)
2411
+
2412
+ # Add to our list of tabs
2413
+ self.tabs.append(tab)
2414
+
2415
+ # Block signals temporarily to improve performance when adding many tabs
2416
+ was_blocked = self.tab_widget.blockSignals(True)
2417
+
2418
+ # Add tab to widget
2419
+ index = self.tab_widget.addTab(tab, title)
2420
+ self.tab_widget.setCurrentIndex(index)
2421
+
2422
+ # Restore signals
2423
+ self.tab_widget.blockSignals(was_blocked)
2424
+
2425
+ # Focus the new tab's query editor
2426
+ tab.query_edit.setFocus()
2427
+
2428
+ # Process events to keep UI responsive
2429
+ QApplication.processEvents()
2430
+
2431
+ # Update completer for the new tab
2432
+ try:
2433
+ from sqlshell.suggester_integration import get_suggestion_manager
2434
+
2435
+ # Get the suggestion manager singleton
2436
+ suggestion_mgr = get_suggestion_manager()
2437
+
2438
+ # Register the new editor with a unique ID
2439
+ editor_id = f"tab_{index}_{id(tab.query_edit)}"
2440
+ suggestion_mgr.register_editor(tab.query_edit, editor_id)
2441
+
2442
+ # Apply the current completer model if available
2443
+ if hasattr(self, '_current_completer_model'):
2444
+ tab.query_edit.update_completer_model(self._current_completer_model)
2445
+ except Exception as e:
2446
+ # Don't let autocomplete errors affect tab creation
2447
+ print(f"Error setting up autocomplete for new tab: {e}")
2448
+
2449
+ return tab
2450
+
2451
+ def duplicate_current_tab(self):
2452
+ """Duplicate the current tab"""
2453
+ if self.tab_widget.count() == 0:
2454
+ return self.add_tab()
2455
+
2456
+ current_idx = self.tab_widget.currentIndex()
2457
+ if current_idx == -1:
2458
+ return
2459
+
2460
+ # Get current tab data
2461
+ current_tab = self.get_current_tab()
2462
+ current_title = self.tab_widget.tabText(current_idx)
2463
+
2464
+ # Create a new tab with "(Copy)" suffix
2465
+ new_title = f"{current_title} (Copy)"
2466
+ new_tab = self.add_tab(new_title)
2467
+
2468
+ # Copy query text
2469
+ new_tab.set_query_text(current_tab.get_query_text())
2470
+
2471
+ # Return focus to the new tab
2472
+ new_tab.query_edit.setFocus()
2473
+
2474
+ return new_tab
2475
+
2476
+ def rename_current_tab(self):
2477
+ """Rename the current tab"""
2478
+ current_idx = self.tab_widget.currentIndex()
2479
+ if current_idx == -1:
2480
+ return
2481
+
2482
+ current_title = self.tab_widget.tabText(current_idx)
2483
+
2484
+ new_title, ok = QInputDialog.getText(
2485
+ self,
2486
+ "Rename Tab",
2487
+ "Enter new tab name:",
2488
+ QLineEdit.EchoMode.Normal,
2489
+ current_title
2490
+ )
2491
+
2492
+ if ok and new_title:
2493
+ self.tab_widget.setTabText(current_idx, new_title)
2494
+
2495
+ def handle_tab_double_click(self, index):
2496
+ """Handle double-clicking on a tab by starting rename immediately"""
2497
+ if index == -1:
2498
+ return
2499
+
2500
+ current_title = self.tab_widget.tabText(index)
2501
+
2502
+ new_title, ok = QInputDialog.getText(
2503
+ self,
2504
+ "Rename Tab",
2505
+ "Enter new tab name:",
2506
+ QLineEdit.EchoMode.Normal,
2507
+ current_title
2508
+ )
2509
+
2510
+ if ok and new_title:
2511
+ self.tab_widget.setTabText(index, new_title)
2512
+
2513
+ def close_tab(self, index):
2514
+ """Close the tab at the given index"""
2515
+ if self.tab_widget.count() <= 1:
2516
+ # Don't close the last tab, just clear it
2517
+ tab = self.get_tab_at_index(index)
2518
+ if tab:
2519
+ tab.set_query_text("")
2520
+ tab.results_table.clearContents()
2521
+ tab.results_table.setRowCount(0)
2522
+ tab.results_table.setColumnCount(0)
2523
+ return
2524
+
2525
+ # Get the widget before removing the tab
2526
+ widget = self.tab_widget.widget(index)
2527
+
2528
+ # Unregister the editor from the suggestion manager before closing
2529
+ try:
2530
+ from sqlshell.suggester_integration import get_suggestion_manager
2531
+ suggestion_mgr = get_suggestion_manager()
2532
+
2533
+ # Find and unregister this editor
2534
+ for editor_id in list(suggestion_mgr._editors.keys()):
2535
+ if editor_id.startswith(f"tab_{index}_") or (hasattr(widget, 'query_edit') and
2536
+ str(id(widget.query_edit)) in editor_id):
2537
+ suggestion_mgr.unregister_editor(editor_id)
2538
+ except Exception as e:
2539
+ # Don't let errors affect tab closing
2540
+ print(f"Error unregistering editor from suggestion manager: {e}")
2541
+
2542
+ # Block signals temporarily to improve performance when removing multiple tabs
2543
+ was_blocked = self.tab_widget.blockSignals(True)
2544
+
2545
+ # Remove the tab
2546
+ self.tab_widget.removeTab(index)
2547
+
2548
+ # Restore signals
2549
+ self.tab_widget.blockSignals(was_blocked)
2550
+
2551
+ # Remove from our list of tabs
2552
+ if widget in self.tabs:
2553
+ self.tabs.remove(widget)
2554
+
2555
+ # Schedule the widget for deletion instead of immediate deletion
2556
+ widget.deleteLater()
2557
+
2558
+ # Process events to keep UI responsive
2559
+ QApplication.processEvents()
2560
+
2561
+ # Update tab indices in the suggestion manager
2562
+ QTimer.singleShot(100, self.update_tab_indices_in_suggestion_manager)
2563
+
2564
+ def update_tab_indices_in_suggestion_manager(self):
2565
+ """Update tab indices in the suggestion manager after tab removal"""
2566
+ try:
2567
+ from sqlshell.suggester_integration import get_suggestion_manager
2568
+ suggestion_mgr = get_suggestion_manager()
2569
+
2570
+ # Get current editors
2571
+ old_editors = suggestion_mgr._editors.copy()
2572
+ old_completers = suggestion_mgr._completers.copy()
2573
+
2574
+ # Clear current registrations
2575
+ suggestion_mgr._editors.clear()
2576
+ suggestion_mgr._completers.clear()
2577
+
2578
+ # Re-register with updated indices
2579
+ for i in range(self.tab_widget.count()):
2580
+ tab = self.tab_widget.widget(i)
2581
+ if tab and hasattr(tab, 'query_edit'):
2582
+ # Register with new index
2583
+ editor_id = f"tab_{i}_{id(tab.query_edit)}"
2584
+ suggestion_mgr._editors[editor_id] = tab.query_edit
2585
+ if hasattr(tab.query_edit, 'completer') and tab.query_edit.completer:
2586
+ suggestion_mgr._completers[editor_id] = tab.query_edit.completer
2587
+ except Exception as e:
2588
+ # Don't let errors affect application
2589
+ print(f"Error updating tab indices in suggestion manager: {e}")
2590
+
2591
+ def close_current_tab(self):
2592
+ """Close the current tab"""
2593
+ current_idx = self.tab_widget.currentIndex()
2594
+ if current_idx != -1:
2595
+ self.close_tab(current_idx)
2596
+
2597
+ def get_current_tab(self):
2598
+ """Get the currently active tab"""
2599
+ current_idx = self.tab_widget.currentIndex()
2600
+ if current_idx == -1:
2601
+ return None
2602
+ return self.tab_widget.widget(current_idx)
2603
+
2604
+ def get_tab_at_index(self, index):
2605
+ """Get the tab at the specified index"""
2606
+ if index < 0 or index >= self.tab_widget.count():
2607
+ return None
2608
+ return self.tab_widget.widget(index)
2609
+
2610
+ def toggle_maximize_window(self):
2611
+ """Toggle between maximized and normal window state"""
2612
+ if self.isMaximized():
2613
+ self.showNormal()
2614
+ self.was_maximized = False
2615
+ else:
2616
+ self.showMaximized()
2617
+ self.was_maximized = True
2618
+
2619
+ def change_zoom(self, factor):
2620
+ """Change the zoom level of the application by adjusting font sizes"""
2621
+ try:
2622
+ # Update font sizes for SQL editors
2623
+ for i in range(self.tab_widget.count()):
2624
+ tab = self.tab_widget.widget(i)
2625
+ if hasattr(tab, 'query_edit'):
2626
+ # Get current font
2627
+ current_font = tab.query_edit.font()
2628
+ current_size = current_font.pointSizeF()
2629
+
2630
+ # Calculate new size with limits to prevent too small/large fonts
2631
+ new_size = current_size * factor
2632
+ if 6 <= new_size <= 72: # Reasonable limits
2633
+ current_font.setPointSizeF(new_size)
2634
+ tab.query_edit.setFont(current_font)
2635
+
2636
+ # Also update the line number area
2637
+ tab.query_edit.update_line_number_area_width(0)
2638
+
2639
+ # Update results table font if needed
2640
+ if hasattr(tab, 'results_table'):
2641
+ table_font = tab.results_table.font()
2642
+ table_size = table_font.pointSizeF()
2643
+ new_table_size = table_size * factor
2644
+
2645
+ if 6 <= new_table_size <= 72:
2646
+ table_font.setPointSizeF(new_table_size)
2647
+ tab.results_table.setFont(table_font)
2648
+ # Resize rows and columns to fit new font size
2649
+ tab.results_table.resizeColumnsToContents()
2650
+ tab.results_table.resizeRowsToContents()
2651
+
2652
+ # Update status bar
2653
+ self.statusBar().showMessage(f"Zoom level adjusted to {int(current_size * factor)}", 2000)
2654
+
2655
+ except Exception as e:
2656
+ self.statusBar().showMessage(f"Error adjusting zoom: {str(e)}", 2000)
2657
+
2658
+ def reset_zoom(self):
2659
+ """Reset zoom level to default"""
2660
+ try:
2661
+ # Default font sizes
2662
+ sql_editor_size = 12
2663
+ table_size = 10
2664
+
2665
+ # Update all tabs
2666
+ for i in range(self.tab_widget.count()):
2667
+ tab = self.tab_widget.widget(i)
2668
+
2669
+ # Reset editor font
2670
+ if hasattr(tab, 'query_edit'):
2671
+ editor_font = tab.query_edit.font()
2672
+ editor_font.setPointSizeF(sql_editor_size)
2673
+ tab.query_edit.setFont(editor_font)
2674
+ tab.query_edit.update_line_number_area_width(0)
2675
+
2676
+ # Reset table font
2677
+ if hasattr(tab, 'results_table'):
2678
+ table_font = tab.results_table.font()
2679
+ table_font.setPointSizeF(table_size)
2680
+ tab.results_table.setFont(table_font)
2681
+ tab.results_table.resizeColumnsToContents()
2682
+ tab.results_table.resizeRowsToContents()
2683
+
2684
+ self.statusBar().showMessage("Zoom level reset to default", 2000)
2685
+
2686
+ except Exception as e:
2687
+ self.statusBar().showMessage(f"Error resetting zoom: {str(e)}", 2000)
2688
+
2689
+ def load_most_recent_project(self):
2690
+ """Load the most recent project if available"""
2691
+ if self.recent_projects:
2692
+ most_recent_project = self.recent_projects[0]
2693
+ if os.path.exists(most_recent_project):
2694
+ self.open_project(most_recent_project)
2695
+ self.statusBar().showMessage(f"Auto-loaded most recent project: {os.path.basename(most_recent_project)}")
2696
+ else:
2697
+ # Remove the non-existent project from the list
2698
+ self.recent_projects.remove(most_recent_project)
2699
+ self.save_recent_projects()
2700
+ # Try the next project if available
2701
+ if self.recent_projects:
2702
+ self.load_most_recent_project()
2703
+
2704
+ def load_delta_table(self):
2705
+ """Load a Delta table from a directory"""
2706
+ if not self.db_manager.is_connected():
2707
+ # Create a default in-memory DuckDB connection if none exists
2708
+ connection_info = self.db_manager.create_memory_connection()
2709
+ self.db_info_label.setText(connection_info)
2710
+
2711
+ # Get directory containing the Delta table
2712
+ delta_dir = QFileDialog.getExistingDirectory(
2713
+ self,
2714
+ "Select Delta Table Directory",
2715
+ "",
2716
+ QFileDialog.Option.ShowDirsOnly | QFileDialog.Option.DontResolveSymlinks
2717
+ )
2718
+
2719
+ if not delta_dir:
2720
+ return
2721
+
2722
+ # Check if this is a valid Delta table directory
2723
+ delta_path = Path(delta_dir)
2724
+ delta_log_path = delta_path / '_delta_log'
2725
+
2726
+ if not delta_log_path.exists():
2727
+ # Ask if they want to select a subdirectory
2728
+ subdirs = [d for d in delta_path.iterdir() if d.is_dir() and (d / '_delta_log').exists()]
2729
+
2730
+ if subdirs:
2731
+ # There are subdirectories with Delta tables
2732
+ msg = QMessageBox()
2733
+ msg.setIcon(QMessageBox.Icon.Information)
2734
+ msg.setWindowTitle("Select Subdirectory")
2735
+ msg.setText(f"The selected directory does not contain a Delta table, but it contains {len(subdirs)} subdirectories with Delta tables.")
2736
+ msg.setInformativeText("Would you like to select one of these subdirectories?")
2737
+ msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2738
+ msg.setDefaultButton(QMessageBox.StandardButton.Yes)
2739
+
2740
+ if msg.exec() == QMessageBox.StandardButton.Yes:
2741
+ # Create a dialog to select a subdirectory
2742
+ subdir_names = [d.name for d in subdirs]
2743
+ subdir, ok = QInputDialog.getItem(
2744
+ self,
2745
+ "Select Delta Subdirectory",
2746
+ "Choose a subdirectory containing a Delta table:",
2747
+ subdir_names,
2748
+ 0,
2749
+ False
2750
+ )
2751
+
2752
+ if not ok or not subdir:
2753
+ return
2754
+
2755
+ delta_dir = str(delta_path / subdir)
2756
+ delta_path = Path(delta_dir)
2757
+ else:
2758
+ # Show error and return
2759
+ QMessageBox.critical(self, "Invalid Delta Table",
2760
+ "The selected directory does not contain a Delta table (_delta_log directory not found).")
2761
+ return
2762
+ else:
2763
+ # No Delta tables found
2764
+ QMessageBox.critical(self, "Invalid Delta Table",
2765
+ "The selected directory does not contain a Delta table (_delta_log directory not found).")
2766
+ return
2767
+
2768
+ try:
2769
+ # Add to recent files
2770
+ self.add_recent_file(delta_dir)
2771
+
2772
+ # Use the database manager to load the Delta table
2773
+ import os
2774
+ table_name, df = self.db_manager.load_file(delta_dir)
2775
+
2776
+ # Update UI using new method
2777
+ self.tables_list.add_table_item(table_name, os.path.basename(delta_dir))
2778
+ self.statusBar().showMessage(f'Loaded Delta table from {delta_dir} as "{table_name}"')
2779
+
2780
+ # Show preview of loaded data
2781
+ preview_df = df.head()
2782
+ self.populate_table(preview_df)
2783
+
2784
+ # Update results title to show preview
2785
+ current_tab = self.get_current_tab()
2786
+ if current_tab:
2787
+ current_tab.results_title.setText(f"PREVIEW: {table_name}")
2788
+
2789
+ # Update completer with new table and column names
2790
+ self.update_completer()
2791
+
2792
+ except Exception as e:
2793
+ error_msg = f'Error loading Delta table from {os.path.basename(delta_dir)}: {str(e)}'
2794
+ self.statusBar().showMessage(error_msg)
2795
+ QMessageBox.critical(self, "Error", error_msg)
2796
+
2797
+ current_tab = self.get_current_tab()
2798
+ if current_tab:
2799
+ current_tab.results_table.setRowCount(0)
2800
+ current_tab.results_table.setColumnCount(0)
2801
+ current_tab.row_count_label.setText("")
2802
+
2803
+ def show_load_dialog(self):
2804
+ """Show a modern dialog with options to load different types of data"""
2805
+ # Create the dialog
2806
+ dialog = QDialog(self)
2807
+ dialog.setWindowTitle("Load Data")
2808
+ dialog.setMinimumWidth(450)
2809
+ dialog.setMinimumHeight(520)
2810
+
2811
+ # Create a layout for the dialog
2812
+ layout = QVBoxLayout(dialog)
2813
+ layout.setSpacing(24)
2814
+ layout.setContentsMargins(30, 30, 30, 30)
2815
+
2816
+ # Header section with title and logo
2817
+ header_layout = QHBoxLayout()
2818
+
2819
+ # Title label with gradient effect
2820
+ title_label = QLabel("Load Data")
2821
+ title_font = QFont()
2822
+ title_font.setPointSize(20)
2823
+ title_font.setBold(True)
2824
+ title_label.setFont(title_font)
2825
+ title_label.setStyleSheet("""
2826
+ font-weight: bold;
2827
+ background: -webkit-linear-gradient(#2C3E50, #3498DB);
2828
+ -webkit-background-clip: text;
2829
+ -webkit-text-fill-color: transparent;
2830
+ """)
2831
+ header_layout.addWidget(title_label, 1)
2832
+
2833
+ # Try to add a small logo image
2834
+ try:
2835
+ icon_path = os.path.join(os.path.dirname(__file__), "resources", "icon.png")
2836
+ if os.path.exists(icon_path):
2837
+ logo_label = QLabel()
2838
+ logo_pixmap = QPixmap(icon_path).scaled(48, 48, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
2839
+ logo_label.setPixmap(logo_pixmap)
2840
+ header_layout.addWidget(logo_label)
2841
+ except Exception:
2842
+ pass # Skip logo if any issues
2843
+
2844
+ layout.addLayout(header_layout)
2845
+
2846
+ # Description with clearer styling
2847
+ desc_label = QLabel("Choose a data source to load into SQLShell")
2848
+ desc_label.setStyleSheet("color: #7F8C8D; font-size: 14px; margin: 4px 0 12px 0;")
2849
+ layout.addWidget(desc_label)
2850
+
2851
+ # Add separator line
2852
+ separator = QFrame()
2853
+ separator.setFrameShape(QFrame.Shape.HLine)
2854
+ separator.setFrameShadow(QFrame.Shadow.Sunken)
2855
+ separator.setStyleSheet("background-color: #E0E0E0; min-height: 1px; max-height: 1px;")
2856
+ layout.addWidget(separator)
2857
+
2858
+ # Create option cards with icons, titles and descriptions
2859
+ options_layout = QVBoxLayout()
2860
+ options_layout.setSpacing(16)
2861
+ options_layout.setContentsMargins(0, 10, 0, 10)
2862
+
2863
+ # Store animation references to prevent garbage collection
2864
+ animations = []
2865
+
2866
+ # Function to create hover animations for cards
2867
+ def create_hover_animations(card):
2868
+ # Store original stylesheet
2869
+ original_style = card.styleSheet()
2870
+ hover_style = """
2871
+ background-color: #F8F9FA;
2872
+ border: 1px solid #3498DB;
2873
+ border-radius: 8px;
2874
+ """
2875
+
2876
+ # Function to handle enter event with animation
2877
+ def enterEvent(event):
2878
+ # Create and configure animation
2879
+ anim = QPropertyAnimation(card, b"geometry")
2880
+ anim.setDuration(150)
2881
+ current_geo = card.geometry()
2882
+ target_geo = QRect(
2883
+ current_geo.x() - 3, # Slight shift to left for effect
2884
+ current_geo.y(),
2885
+ current_geo.width() + 6, # Slight growth in width
2886
+ current_geo.height()
2887
+ )
2888
+ anim.setStartValue(current_geo)
2889
+ anim.setEndValue(target_geo)
2890
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
2891
+
2892
+ # Set hover style
2893
+ card.setStyleSheet(hover_style)
2894
+ # Start animation
2895
+ anim.start()
2896
+ # Keep reference to prevent garbage collection
2897
+ animations.append(anim)
2898
+
2899
+ # Call original enter event if it exists
2900
+ original_enter = getattr(card, "_original_enterEvent", None)
2901
+ if original_enter:
2902
+ original_enter(event)
2903
+
2904
+ # Function to handle leave event with animation
2905
+ def leaveEvent(event):
2906
+ # Create and configure animation to return to original state
2907
+ anim = QPropertyAnimation(card, b"geometry")
2908
+ anim.setDuration(200)
2909
+ current_geo = card.geometry()
2910
+ original_geo = QRect(
2911
+ current_geo.x() + 3, # Shift back to original position
2912
+ current_geo.y(),
2913
+ current_geo.width() - 6, # Shrink back to original width
2914
+ current_geo.height()
2915
+ )
2916
+ anim.setStartValue(current_geo)
2917
+ anim.setEndValue(original_geo)
2918
+ anim.setEasingCurve(QEasingCurve.Type.OutCubic)
2919
+
2920
+ # Restore original style
2921
+ card.setStyleSheet(original_style)
2922
+ # Start animation
2923
+ anim.start()
2924
+ # Keep reference to prevent garbage collection
2925
+ animations.append(anim)
2926
+
2927
+ # Call original leave event if it exists
2928
+ original_leave = getattr(card, "_original_leaveEvent", None)
2929
+ if original_leave:
2930
+ original_leave(event)
2931
+
2932
+ # Store original event handlers and set new ones
2933
+ card._original_enterEvent = card.enterEvent
2934
+ card._original_leaveEvent = card.leaveEvent
2935
+ card.enterEvent = enterEvent
2936
+ card.leaveEvent = leaveEvent
2937
+
2938
+ return card
2939
+
2940
+ # Function to create styled option buttons with descriptions
2941
+ def create_option_button(title, description, icon_name, option_type, accent_color="#3498DB"):
2942
+ # Create container frame
2943
+ container = QFrame()
2944
+ container.setObjectName("optionCard")
2945
+ container.setCursor(Qt.CursorShape.PointingHandCursor)
2946
+ container.setProperty("optionType", option_type)
2947
+
2948
+ # Set frame style
2949
+ container.setFrameShape(QFrame.Shape.StyledPanel)
2950
+ container.setLineWidth(1)
2951
+ container.setMinimumHeight(90)
2952
+ container.setStyleSheet(f"""
2953
+ background-color: #FFFFFF;
2954
+ border-radius: 10px;
2955
+ border: 1px solid #E0E0E0;
2956
+ """)
2957
+
2958
+ # Create layout for the container
2959
+ card_layout = QHBoxLayout(container)
2960
+ card_layout.setContentsMargins(20, 16, 20, 16)
2961
+
2962
+ # Add icon with colored circle background
2963
+ icon_container = QFrame()
2964
+ icon_container.setFixedSize(QSize(50, 50))
2965
+ icon_container.setStyleSheet(f"""
2966
+ background-color: {accent_color}20; /* 20% opacity */
2967
+ border-radius: 25px;
2968
+ border: none;
2969
+ """)
2970
+
2971
+ icon_layout = QHBoxLayout(icon_container)
2972
+ icon_layout.setContentsMargins(0, 0, 0, 0)
2973
+
2974
+ icon_label = QLabel()
2975
+ icon = QIcon.fromTheme(icon_name)
2976
+ icon_pixmap = icon.pixmap(QSize(24, 24))
2977
+ icon_label.setPixmap(icon_pixmap)
2978
+ icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
2979
+ icon_layout.addWidget(icon_label)
2980
+
2981
+ card_layout.addWidget(icon_container)
2982
+
2983
+ # Add text section
2984
+ text_layout = QVBoxLayout()
2985
+ text_layout.setSpacing(4)
2986
+ text_layout.setContentsMargins(12, 0, 0, 0)
2987
+
2988
+ # Add title
2989
+ title_label = QLabel(title)
2990
+ title_font = QFont()
2991
+ title_font.setBold(True)
2992
+ title_font.setPointSize(12)
2993
+ title_label.setFont(title_font)
2994
+ text_layout.addWidget(title_label)
2995
+
2996
+ # Add description
2997
+ desc_label = QLabel(description)
2998
+ desc_label.setWordWrap(True)
2999
+ desc_label.setStyleSheet("color: #7F8C8D; font-size: 11px;")
3000
+ text_layout.addWidget(desc_label)
3001
+
3002
+ card_layout.addLayout(text_layout, 1)
3003
+
3004
+ # Add arrow icon to suggest clickable
3005
+ arrow_label = QLabel("→")
3006
+ arrow_label.setStyleSheet(f"color: {accent_color}; font-size: 16px; font-weight: bold;")
3007
+ card_layout.addWidget(arrow_label)
3008
+
3009
+ # Connect click event
3010
+ container.mousePressEvent = lambda e: self.handle_load_option(dialog, option_type)
3011
+
3012
+ # Apply hover animations
3013
+ container = create_hover_animations(container)
3014
+
3015
+ return container
3016
+
3017
+ # Database option
3018
+ db_option = create_option_button(
3019
+ "Database",
3020
+ "Load SQL database files (SQLite, etc.) to query and analyze.",
3021
+ "database",
3022
+ "database",
3023
+ "#2980B9" # Blue accent
3024
+ )
3025
+ options_layout.addWidget(db_option)
3026
+
3027
+ # Files option
3028
+ files_option = create_option_button(
3029
+ "Data Files",
3030
+ "Load Excel, CSV, Parquet and other data file formats.",
3031
+ "document-new",
3032
+ "files",
3033
+ "#27AE60" # Green accent
3034
+ )
3035
+ options_layout.addWidget(files_option)
3036
+
3037
+ # Delta Table option
3038
+ delta_option = create_option_button(
3039
+ "Delta Table",
3040
+ "Load data from Delta Lake format directories.",
3041
+ "folder-open",
3042
+ "delta",
3043
+ "#8E44AD" # Purple accent
3044
+ )
3045
+ options_layout.addWidget(delta_option)
3046
+
3047
+ # Test Data option
3048
+ test_option = create_option_button(
3049
+ "Test Data",
3050
+ "Generate and load sample data for testing and exploration.",
3051
+ "system-run",
3052
+ "test",
3053
+ "#E67E22" # Orange accent
3054
+ )
3055
+ options_layout.addWidget(test_option)
3056
+
3057
+ layout.addLayout(options_layout)
3058
+
3059
+ # Add spacer
3060
+ layout.addStretch()
3061
+
3062
+ # Add separator line before buttons
3063
+ bottom_separator = QFrame()
3064
+ bottom_separator.setFrameShape(QFrame.Shape.HLine)
3065
+ bottom_separator.setFrameShadow(QFrame.Shadow.Sunken)
3066
+ bottom_separator.setStyleSheet("background-color: #E0E0E0; min-height: 1px; max-height: 1px;")
3067
+ layout.addWidget(bottom_separator)
3068
+
3069
+ # Add cancel button
3070
+ button_layout = QHBoxLayout()
3071
+ button_layout.setSpacing(12)
3072
+ button_layout.setContentsMargins(0, 16, 0, 0)
3073
+ button_layout.addStretch()
3074
+
3075
+ cancel_btn = QPushButton("Cancel")
3076
+ cancel_btn.setFixedWidth(100)
3077
+ cancel_btn.setStyleSheet("""
3078
+ background-color: #F5F5F5;
3079
+ border: 1px solid #E0E0E0;
3080
+ border-radius: 6px;
3081
+ padding: 8px 16px;
3082
+ color: #7F8C8D;
3083
+ font-weight: bold;
3084
+ """)
3085
+ cancel_btn.clicked.connect(dialog.reject)
3086
+ button_layout.addWidget(cancel_btn)
3087
+
3088
+ layout.addLayout(button_layout)
3089
+
3090
+ # Apply modern drop shadow effect to the dialog
3091
+ try:
3092
+ dialog.setGraphicsEffect(None) # Clear any existing effects
3093
+ shadow = QGraphicsDropShadowEffect(dialog)
3094
+ shadow.setBlurRadius(20)
3095
+ shadow.setColor(QColor(0, 0, 0, 50)) # Semi-transparent black
3096
+ shadow.setOffset(0, 0)
3097
+ dialog.setGraphicsEffect(shadow)
3098
+ except Exception:
3099
+ pass # Skip shadow if there are any issues
3100
+
3101
+ # Add custom styling to make the dialog look modern
3102
+ dialog.setStyleSheet("""
3103
+ QDialog {
3104
+ background-color: #FFFFFF;
3105
+ border-radius: 12px;
3106
+ }
3107
+ QLabel {
3108
+ color: #2C3E50;
3109
+ }
3110
+ """)
3111
+
3112
+ # Store dialog animation references in the instance to prevent garbage collection
3113
+ dialog._animations = animations
3114
+
3115
+ # Center the dialog on the parent window
3116
+ if self.geometry().isValid():
3117
+ dialog.move(
3118
+ self.geometry().center().x() - dialog.width() // 2,
3119
+ self.geometry().center().y() - dialog.height() // 2
3120
+ )
3121
+
3122
+ # Show the dialog
3123
+ dialog.exec()
3124
+
3125
+ def handle_load_option(self, dialog, option):
3126
+ """Handle the selected load option"""
3127
+ # Close the dialog
3128
+ dialog.accept()
3129
+
3130
+ # Call the appropriate function based on the selected option
3131
+ if option == "database":
3132
+ self.open_database()
3133
+ elif option == "files":
3134
+ self.browse_files()
3135
+ elif option == "delta":
3136
+ self.load_delta_table()
3137
+ elif option == "test":
3138
+ self.load_test_data()
3139
+
3140
+ def analyze_table_entropy(self, table_name):
3141
+ """Analyze a table with the entropy profiler to identify important columns"""
3142
+ try:
3143
+ # Show a loading indicator
3144
+ self.statusBar().showMessage(f'Analyzing table "{table_name}" columns...')
3145
+
3146
+ # Get the table data
3147
+ if table_name in self.db_manager.loaded_tables:
3148
+ # Check if table needs reloading first
3149
+ if table_name in self.tables_list.tables_needing_reload:
3150
+ # Reload the table immediately
3151
+ self.reload_selected_table(table_name)
3152
+
3153
+ # Get the data as a dataframe
3154
+ query = f'SELECT * FROM "{table_name}"'
3155
+ df = self.db_manager.execute_query(query)
3156
+
3157
+ if df is not None and not df.empty:
3158
+ # Import the entropy profiler
3159
+ from sqlshell.utils.profile_entropy import visualize_profile
3160
+
3161
+ # Create and show the visualization
3162
+ self.statusBar().showMessage(f'Generating entropy profile for "{table_name}"...')
3163
+ vis = visualize_profile(df)
3164
+
3165
+ # Store a reference to prevent garbage collection
3166
+ self._entropy_window = vis
3167
+
3168
+ self.statusBar().showMessage(f'Entropy profile generated for "{table_name}"')
3169
+ else:
3170
+ QMessageBox.warning(self, "Empty Table", f"Table '{table_name}' has no data to analyze.")
3171
+ self.statusBar().showMessage(f'Table "{table_name}" is empty - cannot analyze')
3172
+ else:
3173
+ QMessageBox.warning(self, "Table Not Found", f"Table '{table_name}' not found.")
3174
+ self.statusBar().showMessage(f'Table "{table_name}" not found')
3175
+
3176
+ except Exception as e:
3177
+ QMessageBox.critical(self, "Analysis Error", f"Error analyzing table:\n\n{str(e)}")
3178
+ self.statusBar().showMessage(f'Error analyzing table: {str(e)}')
3179
+
3180
+ def profile_table_structure(self, table_name):
3181
+ """Analyze a table's structure to identify candidate keys and functional dependencies"""
3182
+ try:
3183
+ # Show a loading indicator
3184
+ self.statusBar().showMessage(f'Profiling table structure for "{table_name}"...')
3185
+
3186
+ # Get the table data
3187
+ if table_name in self.db_manager.loaded_tables:
3188
+ # Check if table needs reloading first
3189
+ if table_name in self.tables_list.tables_needing_reload:
3190
+ # Reload the table immediately
3191
+ self.reload_selected_table(table_name)
3192
+
3193
+ # Get the data as a dataframe
3194
+ query = f'SELECT * FROM "{table_name}"'
3195
+ df = self.db_manager.execute_query(query)
3196
+
3197
+ if df is not None and not df.empty:
3198
+ # Import the key profiler
3199
+ from sqlshell.utils.profile_keys import visualize_profile
3200
+
3201
+ # Create and show the visualization
3202
+ self.statusBar().showMessage(f'Generating table profile for "{table_name}"...')
3203
+ vis = visualize_profile(df)
3204
+
3205
+ # Store a reference to prevent garbage collection
3206
+ self._keys_profile_window = vis
3207
+
3208
+ self.statusBar().showMessage(f'Table structure profile generated for "{table_name}"')
3209
+ else:
3210
+ QMessageBox.warning(self, "Empty Table", f"Table '{table_name}' has no data to analyze.")
3211
+ self.statusBar().showMessage(f'Table "{table_name}" is empty - cannot analyze')
3212
+ else:
3213
+ QMessageBox.warning(self, "Table Not Found", f"Table '{table_name}' not found.")
3214
+ self.statusBar().showMessage(f'Table "{table_name}" not found')
3215
+
3216
+ except Exception as e:
3217
+ QMessageBox.critical(self, "Profile Error", f"Error profiling table structure:\n\n{str(e)}")
3218
+ self.statusBar().showMessage(f'Error profiling table: {str(e)}')
3219
+
1796
3220
  def main():
3221
+ # Parse command line arguments
3222
+ parser = argparse.ArgumentParser(description='SQL Shell - SQL Query Tool')
3223
+ parser.add_argument('--no-auto-load', action='store_true',
3224
+ help='Disable auto-loading the most recent project at startup')
3225
+ args = parser.parse_args()
3226
+
1797
3227
  app = QApplication(sys.argv)
1798
3228
  app.setStyle(QStyleFactory.create('Fusion'))
1799
3229
 
3230
+ # Set application icon
3231
+ icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icon.png")
3232
+ if os.path.exists(icon_path):
3233
+ app.setWindowIcon(QIcon(icon_path))
3234
+ else:
3235
+ # Fallback to the main logo if the icon isn't found
3236
+ main_logo_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "sqlshell_logo.png")
3237
+ if os.path.exists(main_logo_path):
3238
+ app.setWindowIcon(QIcon(main_logo_path))
3239
+
1800
3240
  # Ensure we have a valid working directory with pool.db
1801
3241
  package_dir = os.path.dirname(os.path.abspath(__file__))
1802
3242
  working_dir = os.getcwd()
@@ -1812,22 +3252,75 @@ def main():
1812
3252
  if os.path.exists(package_db):
1813
3253
  shutil.copy2(package_db, working_dir)
1814
3254
 
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():
3255
+ try:
3256
+ # Show splash screen
3257
+ splash = AnimatedSplashScreen()
3258
+ splash.show()
3259
+
3260
+ # Process events immediately to ensure the splash screen appears
3261
+ app.processEvents()
3262
+
3263
+ # Create main window but don't show it yet
3264
+ print("Initializing main application...")
3265
+ window = SQLShell()
3266
+
3267
+ # Override auto-load setting if command-line argument is provided
3268
+ if args.no_auto_load:
3269
+ window.auto_load_recent_project = False
3270
+
3271
+ # Define the function to show main window and hide splash
3272
+ def show_main_window():
3273
+ # Properly finish the splash screen
3274
+ if splash:
3275
+ splash.finish(window)
3276
+
3277
+ # Show the main window
3278
+ window.show()
3279
+ timer.stop()
3280
+
3281
+ # Also stop the failsafe timer if it's still running
3282
+ if failsafe_timer.isActive():
3283
+ failsafe_timer.stop()
3284
+
3285
+ print("Main application started")
3286
+
3287
+ # Create a failsafe timer in case the splash screen fails to show
3288
+ def failsafe_show_window():
3289
+ if not window.isVisible():
3290
+ print("Failsafe timer activated - showing main window")
3291
+ if splash:
3292
+ try:
3293
+ # First try to use the proper finish method
3294
+ splash.finish(window)
3295
+ except Exception as e:
3296
+ print(f"Error in failsafe finish: {e}")
3297
+ try:
3298
+ # Fall back to direct close if finish fails
3299
+ splash.close()
3300
+ except Exception:
3301
+ pass
3302
+ window.show()
3303
+
3304
+ # Create and show main window after delay
3305
+ timer = QTimer()
3306
+ timer.setSingleShot(True) # Ensure it only fires once
3307
+ timer.timeout.connect(show_main_window)
3308
+ timer.start(2000) # 2 second delay
3309
+
3310
+ # Failsafe timer - show the main window after 5 seconds even if splash screen fails
3311
+ failsafe_timer = QTimer()
3312
+ failsafe_timer.setSingleShot(True)
3313
+ failsafe_timer.timeout.connect(failsafe_show_window)
3314
+ failsafe_timer.start(5000) # 5 second delay
3315
+
3316
+ sys.exit(app.exec())
3317
+
3318
+ except Exception as e:
3319
+ print(f"Error during startup: {e}")
3320
+ # If there's any error with the splash screen, just show the main window directly
3321
+ window = SQLShell()
1826
3322
  window.show()
1827
- splash.finish(window)
1828
- timer.stop()
1829
-
1830
- sys.exit(app.exec())
3323
+ sys.exit(app.exec())
1831
3324
 
1832
3325
  if __name__ == '__main__':
1833
3326
  main()