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/LICENSE +21 -0
- sqlshell/MANIFEST.in +6 -0
- sqlshell/README.md +59 -0
- sqlshell/__init__.py +1 -1
- sqlshell/context_suggester.py +765 -0
- sqlshell/create_test_data.py +106 -30
- sqlshell/db/__init__.py +5 -0
- sqlshell/db/database_manager.py +837 -0
- sqlshell/editor.py +610 -52
- sqlshell/main.py +2657 -1164
- sqlshell/menus.py +171 -0
- sqlshell/query_tab.py +201 -0
- sqlshell/resources/create_icon.py +106 -28
- sqlshell/resources/create_splash.py +41 -11
- sqlshell/resources/icon.png +0 -0
- sqlshell/resources/logo_large.png +0 -0
- sqlshell/resources/logo_medium.png +0 -0
- sqlshell/resources/logo_small.png +0 -0
- sqlshell/splash_screen.py +276 -48
- sqlshell/styles.py +257 -0
- sqlshell/suggester_integration.py +275 -0
- sqlshell/table_list.py +907 -0
- sqlshell/ui/__init__.py +6 -0
- sqlshell/ui/bar_chart_delegate.py +49 -0
- sqlshell/ui/filter_header.py +403 -0
- sqlshell/utils/__init__.py +8 -0
- sqlshell/utils/profile_entropy.py +347 -0
- sqlshell/utils/profile_keys.py +356 -0
- sqlshell-0.2.0.dist-info/METADATA +198 -0
- sqlshell-0.2.0.dist-info/RECORD +41 -0
- {sqlshell-0.1.8.dist-info → sqlshell-0.2.0.dist-info}/WHEEL +1 -1
- sqlshell/setup.py +0 -42
- sqlshell-0.1.8.dist-info/METADATA +0 -120
- sqlshell-0.1.8.dist-info/RECORD +0 -21
- {sqlshell-0.1.8.dist-info → sqlshell-0.2.0.dist-info}/entry_points.txt +0 -0
- {sqlshell-0.1.8.dist-info → sqlshell-0.2.0.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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(
|
|
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
|
-
#
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
save_project_action.triggered.connect(self.save_project)
|
|
119
|
+
# Remember if the window was maximized
|
|
120
|
+
self.was_maximized = False
|
|
678
121
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
132
|
+
# Setup menus
|
|
133
|
+
setup_menubar(self)
|
|
684
134
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
724
|
-
self.
|
|
725
|
-
self.
|
|
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.
|
|
728
|
-
self.
|
|
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.
|
|
731
|
-
db_buttons_layout.addWidget(self.
|
|
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(
|
|
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 =
|
|
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
#
|
|
839
|
-
|
|
219
|
+
# Connect double-click signal for direct tab renaming
|
|
220
|
+
self.tab_widget.tabBarDoubleClicked.connect(self.handle_tab_double_click)
|
|
840
221
|
|
|
841
|
-
|
|
842
|
-
|
|
222
|
+
# Add a "+" button to the tab bar
|
|
223
|
+
self.tab_widget.setCornerWidget(self.create_tab_corner_widget())
|
|
843
224
|
|
|
844
|
-
self.
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
current_tab.results_table.resizeColumnsToContents()
|
|
966
309
|
|
|
967
310
|
# Restore bar charts for columns that previously had them
|
|
968
|
-
header =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
395
|
+
if not self.db_manager.is_connected():
|
|
1045
396
|
# Create a default in-memory DuckDB connection if none exists
|
|
1046
|
-
|
|
1047
|
-
self.
|
|
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
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
412
|
+
# Use the database manager to load the file
|
|
413
|
+
table_name, df = self.db_manager.load_file(file_name)
|
|
1088
414
|
|
|
1089
|
-
#
|
|
1090
|
-
self.
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
self.
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
self.
|
|
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
|
-
#
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
#
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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.
|
|
1333
|
-
|
|
1334
|
-
self.
|
|
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
|
|
1341
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
#
|
|
1364
|
-
self.
|
|
1365
|
-
self.
|
|
1366
|
-
self.
|
|
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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
853
|
+
large_numbers ln
|
|
854
|
+
WHERE
|
|
855
|
+
ln.LargeValue > 5000000000000
|
|
856
|
+
ORDER BY
|
|
857
|
+
ln.MassiveValue DESC
|
|
1382
858
|
LIMIT 10
|
|
1383
859
|
"""
|
|
1384
|
-
|
|
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
|
|
1393
|
-
self.
|
|
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
|
-
|
|
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
|
|
1428
|
-
self.
|
|
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.
|
|
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
|
-
|
|
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
|
|
1481
|
-
self.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
1002
|
+
for row in range(current_tab.results_table.rowCount()):
|
|
1510
1003
|
row_data = []
|
|
1511
|
-
for column in range(
|
|
1512
|
-
item =
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
1525
|
-
if event.key() == Qt.Key.
|
|
1526
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
1580
|
-
'connection_type': self.
|
|
1178
|
+
'tabs': current_tabs_data,
|
|
1179
|
+
'connection_type': self.db_manager.connection_type
|
|
1581
1180
|
}
|
|
1582
1181
|
|
|
1583
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
1281
|
+
cursor = current_tab.query_edit.textCursor()
|
|
1625
1282
|
cursor.insertText(f"SELECT * FROM {table_name}")
|
|
1626
|
-
|
|
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 =
|
|
1286
|
+
cursor = current_tab.query_edit.textCursor()
|
|
1630
1287
|
cursor.insertText(table_name)
|
|
1631
|
-
|
|
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.
|
|
1395
|
+
if self.db_manager.is_connected() and not skip_confirmation:
|
|
1636
1396
|
reply = QMessageBox.question(self, 'New Project',
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
self.
|
|
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
|
-
'
|
|
1689
|
-
'
|
|
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
|
-
#
|
|
1693
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
-
#
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
|
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
|
-
#
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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,
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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
|
-
#
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
-
|
|
1790
|
-
|
|
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
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
-
|
|
1828
|
-
timer.stop()
|
|
1829
|
-
|
|
1830
|
-
sys.exit(app.exec())
|
|
3323
|
+
sys.exit(app.exec())
|
|
1831
3324
|
|
|
1832
3325
|
if __name__ == '__main__':
|
|
1833
3326
|
main()
|