sqlbench 0.1.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.
- sqlbench/__init__.py +3 -0
- sqlbench/__main__.py +7 -0
- sqlbench/adapters.py +383 -0
- sqlbench/app.py +1398 -0
- sqlbench/database.py +215 -0
- sqlbench/dialogs/__init__.py +1 -0
- sqlbench/dialogs/connection_dialog.py +356 -0
- sqlbench/dialogs/regex_builder_dialog.py +542 -0
- sqlbench/tabs/__init__.py +1 -0
- sqlbench/tabs/spool_tab.py +572 -0
- sqlbench/tabs/sql_tab.py +1827 -0
- sqlbench-0.1.0.dist-info/METADATA +91 -0
- sqlbench-0.1.0.dist-info/RECORD +15 -0
- sqlbench-0.1.0.dist-info/WHEEL +4 -0
- sqlbench-0.1.0.dist-info/entry_points.txt +2 -0
sqlbench/app.py
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
"""Main application window."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
import tkinter.font as tkfont
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
|
|
8
|
+
from sqlbench.database import Database
|
|
9
|
+
from sqlbench.adapters import get_adapter
|
|
10
|
+
from sqlbench.tabs.sql_tab import SQLTab
|
|
11
|
+
from sqlbench.tabs.spool_tab import SpoolTab
|
|
12
|
+
from sqlbench.dialogs.connection_dialog import ConnectionDialog
|
|
13
|
+
from sqlbench.dialogs.regex_builder_dialog import RegexBuilderDialog
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SQLBenchApp:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.root = tk.Tk()
|
|
19
|
+
self.root.title("SQLBench")
|
|
20
|
+
|
|
21
|
+
self.db = Database()
|
|
22
|
+
self.connections = {} # name -> {conn, adapter, db_type, version, info}
|
|
23
|
+
self.tab_count = 0
|
|
24
|
+
self._loading_tables = set() # Track connections currently loading tables
|
|
25
|
+
self._loading_fields = set() # Track tables currently loading fields
|
|
26
|
+
self._connecting = set() # Track connections currently being established
|
|
27
|
+
self._pending_tabs = {} # Tabs to restore after connection: conn_name -> [tab_info, ...]
|
|
28
|
+
|
|
29
|
+
self._restore_geometry()
|
|
30
|
+
self._create_menu()
|
|
31
|
+
self._create_main_layout()
|
|
32
|
+
self._create_statusbar()
|
|
33
|
+
|
|
34
|
+
# Apply theme and font size
|
|
35
|
+
self._apply_theme()
|
|
36
|
+
self._apply_font_size()
|
|
37
|
+
|
|
38
|
+
# Handle window close
|
|
39
|
+
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
40
|
+
|
|
41
|
+
# Restore last connection and tabs after UI is ready
|
|
42
|
+
self.root.after(100, self._restore_session)
|
|
43
|
+
|
|
44
|
+
def _create_menu(self):
|
|
45
|
+
menubar = tk.Menu(self.root)
|
|
46
|
+
self.root.config(menu=menubar)
|
|
47
|
+
|
|
48
|
+
# File menu
|
|
49
|
+
file_menu = tk.Menu(menubar, tearoff=0)
|
|
50
|
+
menubar.add_cascade(label="File", menu=file_menu)
|
|
51
|
+
|
|
52
|
+
self.dark_mode_var = tk.BooleanVar(value=self.db.get_setting("dark_mode", "0") == "1")
|
|
53
|
+
file_menu.add_checkbutton(label="Dark Mode", variable=self.dark_mode_var,
|
|
54
|
+
command=self._toggle_dark_mode)
|
|
55
|
+
file_menu.add_command(label="Settings...", command=self._show_settings)
|
|
56
|
+
file_menu.add_separator()
|
|
57
|
+
file_menu.add_command(label="Reset Layout", command=self._reset_layout)
|
|
58
|
+
file_menu.add_separator()
|
|
59
|
+
file_menu.add_command(label="Exit", command=self._on_close)
|
|
60
|
+
|
|
61
|
+
# Load font size setting
|
|
62
|
+
self.font_size = int(self.db.get_setting("font_size", "10"))
|
|
63
|
+
|
|
64
|
+
# Help menu
|
|
65
|
+
help_menu = tk.Menu(menubar, tearoff=0)
|
|
66
|
+
menubar.add_cascade(label="Help", menu=help_menu)
|
|
67
|
+
help_menu.add_command(label="About", command=self._show_about)
|
|
68
|
+
|
|
69
|
+
def _create_main_layout(self):
|
|
70
|
+
# Main paned window: left (connections) | right (tabs)
|
|
71
|
+
self.main_paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
|
|
72
|
+
self.main_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
|
73
|
+
|
|
74
|
+
# Left panel - Connections
|
|
75
|
+
conn_frame = ttk.Frame(self.main_paned)
|
|
76
|
+
self.main_paned.add(conn_frame, weight=0)
|
|
77
|
+
|
|
78
|
+
# Connections header
|
|
79
|
+
ttk.Label(conn_frame, text="Connections", font=("TkDefaultFont", 9, "bold")).pack(anchor=tk.W, padx=2, pady=(2, 0))
|
|
80
|
+
|
|
81
|
+
# Filter entry
|
|
82
|
+
filter_frame = ttk.Frame(conn_frame)
|
|
83
|
+
filter_frame.pack(fill=tk.X, padx=2, pady=2)
|
|
84
|
+
ttk.Label(filter_frame, text="Filter:").pack(side=tk.LEFT)
|
|
85
|
+
self.filter_var = tk.StringVar()
|
|
86
|
+
self.filter_var.trace_add("write", self._on_filter_change)
|
|
87
|
+
self.filter_entry = ttk.Entry(filter_frame, textvariable=self.filter_var, width=15)
|
|
88
|
+
self.filter_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(2, 0))
|
|
89
|
+
ttk.Button(filter_frame, text="AI", width=3, command=self._open_regex_builder).pack(side=tk.LEFT, padx=(2, 0))
|
|
90
|
+
|
|
91
|
+
# Store loaded tables for filtering
|
|
92
|
+
self._loaded_tables = {} # conn_name -> list of (schema, table_name, table_type)
|
|
93
|
+
|
|
94
|
+
# Connection tree
|
|
95
|
+
self.conn_tree = ttk.Treeview(conn_frame, show="tree", selectmode="browse")
|
|
96
|
+
conn_scroll = ttk.Scrollbar(conn_frame, orient=tk.VERTICAL, command=self.conn_tree.yview)
|
|
97
|
+
self.conn_tree.configure(yscrollcommand=conn_scroll.set)
|
|
98
|
+
|
|
99
|
+
conn_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
100
|
+
self.conn_tree.pack(fill=tk.BOTH, expand=True)
|
|
101
|
+
|
|
102
|
+
# Buttons below connection list
|
|
103
|
+
btn_frame = ttk.Frame(conn_frame)
|
|
104
|
+
btn_frame.pack(fill=tk.X, padx=2, pady=2)
|
|
105
|
+
ttk.Button(btn_frame, text="+", width=3, command=self._new_connection).pack(side=tk.LEFT, padx=1)
|
|
106
|
+
ttk.Button(btn_frame, text="-", width=3, command=self._delete_connection).pack(side=tk.LEFT, padx=1)
|
|
107
|
+
|
|
108
|
+
# Right-click context menu
|
|
109
|
+
self.conn_menu = tk.Menu(self.root, tearoff=0)
|
|
110
|
+
self.conn_menu.add_command(label="Connect", command=self._connect_selected)
|
|
111
|
+
self.conn_menu.add_command(label="Disconnect", command=self._disconnect_selected)
|
|
112
|
+
self.conn_menu.add_separator()
|
|
113
|
+
self.conn_menu.add_command(label="New SQL", command=self._new_sql_tab)
|
|
114
|
+
self.conn_menu.add_command(label="New Spool Files", command=self._new_spool_tab)
|
|
115
|
+
self.conn_menu.add_separator()
|
|
116
|
+
self.conn_menu.add_command(label="Show First 1000 Rows", command=self._show_first_1000_rows)
|
|
117
|
+
self.conn_menu.add_separator()
|
|
118
|
+
self.conn_menu.add_command(label="New Connection...", command=self._new_connection)
|
|
119
|
+
self.conn_menu.add_command(label="Edit...", command=self._edit_connection)
|
|
120
|
+
self.conn_menu.add_command(label="Delete", command=self._delete_connection)
|
|
121
|
+
|
|
122
|
+
self.conn_tree.bind("<Button-3>", self._show_conn_menu)
|
|
123
|
+
self.conn_tree.bind("<Double-1>", self._on_tree_double_click)
|
|
124
|
+
self.conn_tree.bind("<<TreeviewOpen>>", self._on_tree_expand)
|
|
125
|
+
|
|
126
|
+
# Dismiss menu when clicking elsewhere
|
|
127
|
+
self.root.bind("<Button-1>", self._dismiss_menus)
|
|
128
|
+
|
|
129
|
+
# SQL tab keyboard shortcuts
|
|
130
|
+
self.root.bind("<F5>", self._run_active_sql_query)
|
|
131
|
+
self.root.bind("<Escape>", self._cancel_active_sql_query)
|
|
132
|
+
self.root.bind("<Control-s>", self._save_active_sql_query)
|
|
133
|
+
self.root.bind("<Control-S>", self._save_active_sql_query)
|
|
134
|
+
self.root.bind("<Control-o>", self._load_active_sql_query)
|
|
135
|
+
self.root.bind("<Control-O>", self._load_active_sql_query)
|
|
136
|
+
|
|
137
|
+
# Right panel - Notebook for tabs
|
|
138
|
+
self._create_closeable_notebook_style()
|
|
139
|
+
self.notebook = ttk.Notebook(self.main_paned)
|
|
140
|
+
self.main_paned.add(self.notebook, weight=1)
|
|
141
|
+
|
|
142
|
+
# Enable tab closing via middle-click and right-click menu
|
|
143
|
+
self.notebook.bind("<Button-2>", self._close_tab_middle_click) # Middle click
|
|
144
|
+
self.notebook.bind("<Button-3>", self._show_tab_menu) # Right click
|
|
145
|
+
|
|
146
|
+
# Enable tab drag-and-drop reordering
|
|
147
|
+
self._drag_start_index = None
|
|
148
|
+
self.notebook.bind("<Button-1>", self._tab_drag_start)
|
|
149
|
+
self.notebook.bind("<B1-Motion>", self._tab_drag_motion)
|
|
150
|
+
self.notebook.bind("<ButtonRelease-1>", self._tab_drag_end)
|
|
151
|
+
|
|
152
|
+
# Tab context menu
|
|
153
|
+
self.tab_menu = tk.Menu(self.root, tearoff=0)
|
|
154
|
+
self.tab_menu.add_command(label="Close Tab", command=self._close_current_tab)
|
|
155
|
+
|
|
156
|
+
self._refresh_connections()
|
|
157
|
+
|
|
158
|
+
def _create_closeable_notebook_style(self):
|
|
159
|
+
"""Create a notebook style with close button character in tab names."""
|
|
160
|
+
style = ttk.Style()
|
|
161
|
+
|
|
162
|
+
# Use clam theme as base for consistent cross-platform look
|
|
163
|
+
if "clam" in style.theme_names():
|
|
164
|
+
style.theme_use("clam")
|
|
165
|
+
|
|
166
|
+
def _create_statusbar(self):
|
|
167
|
+
self.statusbar = ttk.Label(self.root, text="Ready", relief=tk.SUNKEN, anchor=tk.W)
|
|
168
|
+
self.statusbar.pack(side=tk.BOTTOM, fill=tk.X)
|
|
169
|
+
|
|
170
|
+
def _refresh_connections(self):
|
|
171
|
+
"""Refresh the connection tree."""
|
|
172
|
+
self.conn_tree.delete(*self.conn_tree.get_children())
|
|
173
|
+
for conn in self.db.get_connections():
|
|
174
|
+
name = conn["name"]
|
|
175
|
+
db_type = conn.get("db_type", "ibmi")
|
|
176
|
+
type_indicator = {"ibmi": "[i]", "mysql": "[M]", "postgresql": "[P]"}.get(db_type, "[?]")
|
|
177
|
+
|
|
178
|
+
# Show connected status
|
|
179
|
+
status = " (connected)" if name in self.connections else ""
|
|
180
|
+
node_id = self.conn_tree.insert("", tk.END, iid=name, text=f"{type_indicator} {name}{status}")
|
|
181
|
+
|
|
182
|
+
# Add placeholder child so connection can be expanded (if connected)
|
|
183
|
+
if name in self.connections:
|
|
184
|
+
# Check if tables already loaded
|
|
185
|
+
if not self.conn_tree.get_children(node_id):
|
|
186
|
+
self.conn_tree.insert(node_id, tk.END, iid=f"{name}::loading", text="Loading...")
|
|
187
|
+
|
|
188
|
+
def _on_tree_double_click(self, event):
|
|
189
|
+
"""Handle double-click on tree item - toggle open/closed state."""
|
|
190
|
+
try:
|
|
191
|
+
item = self.conn_tree.identify_row(event.y)
|
|
192
|
+
if not item:
|
|
193
|
+
return "break"
|
|
194
|
+
|
|
195
|
+
# Skip loading/error placeholder nodes and fields
|
|
196
|
+
if "::loading" in item or "::error" in item or "::field_" in item:
|
|
197
|
+
return "break"
|
|
198
|
+
|
|
199
|
+
# Toggle open/closed state
|
|
200
|
+
is_open = self.conn_tree.item(item, "open")
|
|
201
|
+
if is_open:
|
|
202
|
+
self.conn_tree.item(item, open=False)
|
|
203
|
+
else:
|
|
204
|
+
# For connections not yet connected, connect first
|
|
205
|
+
if "::" not in item and item not in self.connections:
|
|
206
|
+
self._connect_selected()
|
|
207
|
+
else:
|
|
208
|
+
self.conn_tree.item(item, open=True)
|
|
209
|
+
# Manually trigger expand logic since <<TreeviewOpen>> may not fire
|
|
210
|
+
self._handle_node_expand(item)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass # Don't let tree errors crash the app
|
|
213
|
+
|
|
214
|
+
return "break" # Prevent default double-click behavior
|
|
215
|
+
|
|
216
|
+
def _handle_node_expand(self, item):
|
|
217
|
+
"""Handle expansion of a node - load tables or fields if needed."""
|
|
218
|
+
try:
|
|
219
|
+
children = self.conn_tree.get_children(item)
|
|
220
|
+
if not children:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
first_child = children[0]
|
|
224
|
+
|
|
225
|
+
# Connection node - load tables
|
|
226
|
+
if "::" not in item and item in self.connections:
|
|
227
|
+
if first_child.endswith("::loading"):
|
|
228
|
+
self._load_tables_for_connection(item)
|
|
229
|
+
|
|
230
|
+
# Table node - load fields
|
|
231
|
+
elif first_child.endswith("::fields_loading"):
|
|
232
|
+
conn_name = item.split("::")[0]
|
|
233
|
+
table_ref = item.split("::", 1)[1] if "::" in item else None
|
|
234
|
+
if conn_name in self.connections and table_ref and "." in table_ref:
|
|
235
|
+
self._load_fields_for_table(conn_name, item, table_ref)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
def _on_tree_expand(self, event):
|
|
240
|
+
"""Handle tree node expansion via arrow click."""
|
|
241
|
+
item = self.conn_tree.focus()
|
|
242
|
+
if item:
|
|
243
|
+
self._handle_node_expand(item)
|
|
244
|
+
|
|
245
|
+
def _load_tables_for_connection(self, conn_name):
|
|
246
|
+
"""Load tables for a connection."""
|
|
247
|
+
if conn_name not in self.connections:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
# Prevent concurrent loading
|
|
251
|
+
if conn_name in self._loading_tables:
|
|
252
|
+
return
|
|
253
|
+
self._loading_tables.add(conn_name)
|
|
254
|
+
|
|
255
|
+
conn_data = self.connections[conn_name]
|
|
256
|
+
adapter = conn_data["adapter"]
|
|
257
|
+
db_conn = conn_data["conn"]
|
|
258
|
+
|
|
259
|
+
def load_tables():
|
|
260
|
+
try:
|
|
261
|
+
cursor = db_conn.cursor()
|
|
262
|
+
cursor.execute(adapter.get_tables_query())
|
|
263
|
+
tables = cursor.fetchall()
|
|
264
|
+
cursor.close()
|
|
265
|
+
# Update UI on main thread
|
|
266
|
+
self.root.after(0, lambda: self._on_tables_loaded(conn_name, tables))
|
|
267
|
+
except Exception as e:
|
|
268
|
+
self.root.after(0, lambda: self._on_tables_load_error(conn_name, str(e)))
|
|
269
|
+
|
|
270
|
+
# Run in background thread
|
|
271
|
+
import threading
|
|
272
|
+
thread = threading.Thread(target=load_tables, daemon=True)
|
|
273
|
+
thread.start()
|
|
274
|
+
|
|
275
|
+
def _on_tables_loaded(self, conn_name, tables):
|
|
276
|
+
"""Handle tables loaded - runs on main thread."""
|
|
277
|
+
self._loading_tables.discard(conn_name)
|
|
278
|
+
self._populate_tables(conn_name, tables)
|
|
279
|
+
|
|
280
|
+
def _on_tables_load_error(self, conn_name, error):
|
|
281
|
+
"""Handle tables load error - runs on main thread."""
|
|
282
|
+
self._loading_tables.discard(conn_name)
|
|
283
|
+
self._tables_load_error(conn_name, error)
|
|
284
|
+
|
|
285
|
+
def _populate_tables(self, conn_name, tables):
|
|
286
|
+
"""Populate the tree with tables."""
|
|
287
|
+
# Store tables for filtering
|
|
288
|
+
self._loaded_tables[conn_name] = list(tables)
|
|
289
|
+
|
|
290
|
+
# Apply current filter
|
|
291
|
+
self._display_filtered_tables(conn_name)
|
|
292
|
+
|
|
293
|
+
def _display_filtered_tables(self, conn_name):
|
|
294
|
+
"""Display tables for a connection, applying the current filter."""
|
|
295
|
+
if conn_name not in self._loaded_tables:
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# Remove existing children
|
|
299
|
+
for child in self.conn_tree.get_children(conn_name):
|
|
300
|
+
self.conn_tree.delete(child)
|
|
301
|
+
|
|
302
|
+
tables = self._loaded_tables[conn_name]
|
|
303
|
+
filter_text = self.filter_var.get().strip()
|
|
304
|
+
|
|
305
|
+
# Try to compile as regex, fall back to literal match if invalid
|
|
306
|
+
filter_regex = None
|
|
307
|
+
if filter_text:
|
|
308
|
+
try:
|
|
309
|
+
filter_regex = re.compile(filter_text, re.IGNORECASE)
|
|
310
|
+
except re.error:
|
|
311
|
+
# Invalid regex - treat as literal string match
|
|
312
|
+
filter_regex = None
|
|
313
|
+
|
|
314
|
+
# Group tables by schema, applying filter
|
|
315
|
+
schemas = {}
|
|
316
|
+
for row in tables:
|
|
317
|
+
schema, table_name, table_type = row[0], row[1], row[2]
|
|
318
|
+
|
|
319
|
+
# Apply filter to table name or schema name
|
|
320
|
+
if filter_text:
|
|
321
|
+
if filter_regex:
|
|
322
|
+
# Use regex search
|
|
323
|
+
if not filter_regex.search(table_name) and not filter_regex.search(schema):
|
|
324
|
+
continue
|
|
325
|
+
else:
|
|
326
|
+
# Fall back to literal case-insensitive match
|
|
327
|
+
filter_upper = filter_text.upper()
|
|
328
|
+
if filter_upper not in table_name.upper() and filter_upper not in schema.upper():
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
if schema not in schemas:
|
|
332
|
+
schemas[schema] = []
|
|
333
|
+
schemas[schema].append((table_name, table_type))
|
|
334
|
+
|
|
335
|
+
# Add schema nodes and tables
|
|
336
|
+
for schema in sorted(schemas.keys()):
|
|
337
|
+
schema_id = f"{conn_name}::{schema}"
|
|
338
|
+
table_count = len(schemas[schema])
|
|
339
|
+
self.conn_tree.insert(conn_name, tk.END, iid=schema_id, text=f"[S] {schema} ({table_count})")
|
|
340
|
+
|
|
341
|
+
for table_name, table_type in sorted(schemas[schema]):
|
|
342
|
+
type_char = "V" if table_type in ("VIEW", "V") else "T"
|
|
343
|
+
table_id = f"{conn_name}::{schema}.{table_name}"
|
|
344
|
+
self.conn_tree.insert(schema_id, tk.END, iid=table_id, text=f"[{type_char}] {table_name}")
|
|
345
|
+
# Add placeholder for fields
|
|
346
|
+
self.conn_tree.insert(table_id, tk.END, iid=f"{table_id}::fields_loading", text="Loading...")
|
|
347
|
+
|
|
348
|
+
# Auto-expand connection if filter is active
|
|
349
|
+
if filter_text and schemas:
|
|
350
|
+
self.conn_tree.item(conn_name, open=True)
|
|
351
|
+
for schema in schemas.keys():
|
|
352
|
+
self.conn_tree.item(f"{conn_name}::{schema}", open=True)
|
|
353
|
+
|
|
354
|
+
def _on_filter_change(self, *args):
|
|
355
|
+
"""Handle filter text change."""
|
|
356
|
+
# Reapply filter to all loaded connections
|
|
357
|
+
for conn_name in self._loaded_tables:
|
|
358
|
+
self._display_filtered_tables(conn_name)
|
|
359
|
+
|
|
360
|
+
def _open_regex_builder(self):
|
|
361
|
+
"""Open the AI regex builder dialog."""
|
|
362
|
+
def on_regex_generated(regex):
|
|
363
|
+
self.filter_var.set(regex)
|
|
364
|
+
|
|
365
|
+
RegexBuilderDialog(self.root, callback=on_regex_generated)
|
|
366
|
+
|
|
367
|
+
def _tables_load_error(self, conn_name, error):
|
|
368
|
+
"""Handle error loading tables."""
|
|
369
|
+
# Remove loading placeholder
|
|
370
|
+
for child in self.conn_tree.get_children(conn_name):
|
|
371
|
+
self.conn_tree.delete(child)
|
|
372
|
+
|
|
373
|
+
self.conn_tree.insert(conn_name, tk.END, iid=f"{conn_name}::error", text=f"Error: {error[:50]}")
|
|
374
|
+
|
|
375
|
+
def _load_fields_for_table(self, conn_name, table_node_id, table_ref):
|
|
376
|
+
"""Load fields for a table."""
|
|
377
|
+
if conn_name not in self.connections:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Prevent concurrent loading
|
|
381
|
+
if table_node_id in self._loading_fields:
|
|
382
|
+
return
|
|
383
|
+
self._loading_fields.add(table_node_id)
|
|
384
|
+
|
|
385
|
+
conn_data = self.connections[conn_name]
|
|
386
|
+
adapter = conn_data["adapter"]
|
|
387
|
+
db_conn = conn_data["conn"]
|
|
388
|
+
|
|
389
|
+
def load_fields():
|
|
390
|
+
try:
|
|
391
|
+
columns_query = adapter.get_columns_query([table_ref])
|
|
392
|
+
if not columns_query:
|
|
393
|
+
self.root.after(0, lambda: self._on_fields_error(table_node_id, "Not supported"))
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
cursor = db_conn.cursor()
|
|
397
|
+
cursor.execute(columns_query)
|
|
398
|
+
fields = cursor.fetchall()
|
|
399
|
+
cursor.close()
|
|
400
|
+
# Update UI on main thread
|
|
401
|
+
self.root.after(0, lambda: self._on_fields_loaded(table_node_id, fields))
|
|
402
|
+
except Exception as e:
|
|
403
|
+
self.root.after(0, lambda: self._on_fields_error(table_node_id, str(e)))
|
|
404
|
+
|
|
405
|
+
# Run in background thread
|
|
406
|
+
import threading
|
|
407
|
+
thread = threading.Thread(target=load_fields, daemon=True)
|
|
408
|
+
thread.start()
|
|
409
|
+
|
|
410
|
+
def _on_fields_loaded(self, table_node_id, fields):
|
|
411
|
+
"""Handle fields loaded - runs on main thread."""
|
|
412
|
+
self._loading_fields.discard(table_node_id)
|
|
413
|
+
self._populate_fields(table_node_id, fields)
|
|
414
|
+
|
|
415
|
+
def _on_fields_error(self, table_node_id, error):
|
|
416
|
+
"""Handle fields load error - runs on main thread."""
|
|
417
|
+
self._loading_fields.discard(table_node_id)
|
|
418
|
+
self._fields_load_error(table_node_id, error)
|
|
419
|
+
|
|
420
|
+
def _populate_fields(self, table_node_id, fields):
|
|
421
|
+
"""Populate the tree with fields."""
|
|
422
|
+
# Remove loading placeholder
|
|
423
|
+
for child in self.conn_tree.get_children(table_node_id):
|
|
424
|
+
self.conn_tree.delete(child)
|
|
425
|
+
|
|
426
|
+
# Update table node text with field count
|
|
427
|
+
current_text = self.conn_tree.item(table_node_id, "text")
|
|
428
|
+
# Remove old count if present
|
|
429
|
+
if " (" in current_text and current_text.endswith(")"):
|
|
430
|
+
# Check if it's a field count (not type indicator)
|
|
431
|
+
last_paren = current_text.rfind(" (")
|
|
432
|
+
if last_paren > 3: # After "[T] "
|
|
433
|
+
current_text = current_text[:last_paren]
|
|
434
|
+
self.conn_tree.item(table_node_id, text=f"{current_text} ({len(fields)})")
|
|
435
|
+
|
|
436
|
+
# Fields format from adapter: (schema, table, column_name, data_type, length, scale)
|
|
437
|
+
for i, row in enumerate(fields):
|
|
438
|
+
col_name = row[2]
|
|
439
|
+
data_type = row[3]
|
|
440
|
+
length = row[4]
|
|
441
|
+
scale = row[5]
|
|
442
|
+
|
|
443
|
+
# Build type string
|
|
444
|
+
if length and scale:
|
|
445
|
+
type_str = f"{data_type}({length},{scale})"
|
|
446
|
+
elif length:
|
|
447
|
+
type_str = f"{data_type}({length})"
|
|
448
|
+
else:
|
|
449
|
+
type_str = str(data_type)
|
|
450
|
+
|
|
451
|
+
field_id = f"{table_node_id}::field_{i}"
|
|
452
|
+
self.conn_tree.insert(table_node_id, tk.END, iid=field_id, text=f"{col_name} ({type_str})")
|
|
453
|
+
|
|
454
|
+
def _fields_load_error(self, table_node_id, error):
|
|
455
|
+
"""Handle error loading fields."""
|
|
456
|
+
# Remove loading placeholder
|
|
457
|
+
for child in self.conn_tree.get_children(table_node_id):
|
|
458
|
+
self.conn_tree.delete(child)
|
|
459
|
+
|
|
460
|
+
self.conn_tree.insert(table_node_id, tk.END, iid=f"{table_node_id}::error", text=f"Error: {error[:40]}")
|
|
461
|
+
|
|
462
|
+
def _dismiss_menus(self, event=None):
|
|
463
|
+
"""Dismiss any open context menus."""
|
|
464
|
+
try:
|
|
465
|
+
self.conn_menu.unpost()
|
|
466
|
+
self.tab_menu.unpost()
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
def _run_active_sql_query(self, event=None):
|
|
471
|
+
"""Run query on the currently active SQL tab."""
|
|
472
|
+
try:
|
|
473
|
+
selected = self.notebook.select()
|
|
474
|
+
if not selected:
|
|
475
|
+
return
|
|
476
|
+
tab_frame = self.notebook.nametowidget(selected)
|
|
477
|
+
if hasattr(tab_frame, 'sql_tab'):
|
|
478
|
+
tab_frame.sql_tab._run_query()
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
def _cancel_active_sql_query(self, event=None):
|
|
483
|
+
"""Cancel query on the currently active SQL tab."""
|
|
484
|
+
try:
|
|
485
|
+
selected = self.notebook.select()
|
|
486
|
+
if not selected:
|
|
487
|
+
return
|
|
488
|
+
tab_frame = self.notebook.nametowidget(selected)
|
|
489
|
+
if hasattr(tab_frame, 'sql_tab'):
|
|
490
|
+
tab_frame.sql_tab._cancel_query()
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
def _save_active_sql_query(self, event=None):
|
|
495
|
+
"""Save query from the currently active SQL tab."""
|
|
496
|
+
try:
|
|
497
|
+
selected = self.notebook.select()
|
|
498
|
+
if not selected:
|
|
499
|
+
return
|
|
500
|
+
tab_frame = self.notebook.nametowidget(selected)
|
|
501
|
+
if hasattr(tab_frame, 'sql_tab'):
|
|
502
|
+
tab_frame.sql_tab._save_query()
|
|
503
|
+
return "break" # Prevent default Ctrl+S behavior
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
def _load_active_sql_query(self, event=None):
|
|
508
|
+
"""Load query into the currently active SQL tab."""
|
|
509
|
+
try:
|
|
510
|
+
selected = self.notebook.select()
|
|
511
|
+
if not selected:
|
|
512
|
+
return
|
|
513
|
+
tab_frame = self.notebook.nametowidget(selected)
|
|
514
|
+
if hasattr(tab_frame, 'sql_tab'):
|
|
515
|
+
tab_frame.sql_tab._load_query()
|
|
516
|
+
return "break" # Prevent default Ctrl+O behavior
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
def _show_conn_menu(self, event):
|
|
521
|
+
"""Show context menu on right-click."""
|
|
522
|
+
item = self.conn_tree.identify_row(event.y)
|
|
523
|
+
if item:
|
|
524
|
+
self.conn_tree.selection_set(item)
|
|
525
|
+
|
|
526
|
+
# Get connection name (handle table/schema selections)
|
|
527
|
+
conn_name = item.split("::")[0] if "::" in item else item
|
|
528
|
+
|
|
529
|
+
# Enable/disable menu items based on connection state
|
|
530
|
+
is_connected = conn_name in self.connections
|
|
531
|
+
supports_spool = False
|
|
532
|
+
if is_connected:
|
|
533
|
+
supports_spool = self.connections[conn_name]["adapter"].supports_spool
|
|
534
|
+
|
|
535
|
+
# Check if clicked on a table (format: conn_name::schema.table)
|
|
536
|
+
is_table = "::" in item and "." in item.split("::", 1)[1]
|
|
537
|
+
|
|
538
|
+
self.conn_menu.entryconfig("Connect", state=tk.DISABLED if is_connected else tk.NORMAL)
|
|
539
|
+
self.conn_menu.entryconfig("Disconnect", state=tk.NORMAL if is_connected else tk.DISABLED)
|
|
540
|
+
self.conn_menu.entryconfig("New SQL", state=tk.NORMAL if is_connected else tk.DISABLED)
|
|
541
|
+
self.conn_menu.entryconfig("New Spool Files", state=tk.NORMAL if is_connected and supports_spool else tk.DISABLED)
|
|
542
|
+
self.conn_menu.entryconfig("Show First 1000 Rows", state=tk.NORMAL if is_connected and is_table else tk.DISABLED)
|
|
543
|
+
self.conn_menu.post(event.x_root, event.y_root)
|
|
544
|
+
|
|
545
|
+
def _show_first_1000_rows(self):
|
|
546
|
+
"""Show first 1000 rows of the selected table."""
|
|
547
|
+
selection = self.conn_tree.selection()
|
|
548
|
+
if not selection:
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
item = selection[0]
|
|
552
|
+
if "::" not in item:
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
conn_name = item.split("::")[0]
|
|
556
|
+
table_ref = item.split("::", 1)[1]
|
|
557
|
+
|
|
558
|
+
# table_ref is schema.table
|
|
559
|
+
if "." not in table_ref:
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
if conn_name not in self.connections:
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
conn_data = self.connections[conn_name]
|
|
566
|
+
adapter = conn_data["adapter"]
|
|
567
|
+
|
|
568
|
+
# Build query with appropriate LIMIT syntax
|
|
569
|
+
sql = adapter.get_select_limit_query(table_ref, 1000)
|
|
570
|
+
|
|
571
|
+
# Select the connection and create a new SQL tab
|
|
572
|
+
self.conn_tree.selection_set(conn_name)
|
|
573
|
+
self._new_sql_tab(initial_sql=sql)
|
|
574
|
+
|
|
575
|
+
# Execute the query
|
|
576
|
+
self.root.after(100, self._run_active_sql_query)
|
|
577
|
+
|
|
578
|
+
def _get_selected_connection(self):
|
|
579
|
+
"""Get the currently selected connection name."""
|
|
580
|
+
selection = self.conn_tree.selection()
|
|
581
|
+
if not selection:
|
|
582
|
+
return None
|
|
583
|
+
item = selection[0]
|
|
584
|
+
# Handle table/schema selections (format: "conn_name::schema.table")
|
|
585
|
+
if "::" in item:
|
|
586
|
+
return item.split("::")[0]
|
|
587
|
+
return item
|
|
588
|
+
|
|
589
|
+
def _connect_selected(self):
|
|
590
|
+
"""Connect to the selected connection (async)."""
|
|
591
|
+
name = self._get_selected_connection()
|
|
592
|
+
if not name or name in self.connections:
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
# Check if already connecting
|
|
596
|
+
if name in self._connecting:
|
|
597
|
+
return
|
|
598
|
+
self._connecting.add(name)
|
|
599
|
+
|
|
600
|
+
conn_info = self.db.get_connection(name)
|
|
601
|
+
if not conn_info:
|
|
602
|
+
self._connecting.discard(name)
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
self.statusbar.config(text=f"Connecting to {name}...")
|
|
606
|
+
|
|
607
|
+
def do_connect():
|
|
608
|
+
try:
|
|
609
|
+
db_type = conn_info.get("db_type", "ibmi")
|
|
610
|
+
adapter = get_adapter(db_type)
|
|
611
|
+
|
|
612
|
+
db_conn = adapter.connect(
|
|
613
|
+
host=conn_info['host'],
|
|
614
|
+
user=conn_info['user'],
|
|
615
|
+
password=conn_info['password'],
|
|
616
|
+
port=conn_info.get('port'),
|
|
617
|
+
database=conn_info.get('database')
|
|
618
|
+
)
|
|
619
|
+
version = adapter.get_version(db_conn)
|
|
620
|
+
|
|
621
|
+
self.root.after(0, lambda: self._on_connected(name, db_conn, adapter, db_type, version, conn_info))
|
|
622
|
+
except Exception as e:
|
|
623
|
+
self.root.after(0, lambda: self._on_connect_error(name, str(e)))
|
|
624
|
+
|
|
625
|
+
import threading
|
|
626
|
+
thread = threading.Thread(target=do_connect, daemon=True)
|
|
627
|
+
thread.start()
|
|
628
|
+
|
|
629
|
+
def _on_connected(self, name, db_conn, adapter, db_type, version, conn_info):
|
|
630
|
+
"""Handle successful connection (main thread)."""
|
|
631
|
+
self._connecting.discard(name)
|
|
632
|
+
|
|
633
|
+
self.connections[name] = {
|
|
634
|
+
"conn": db_conn,
|
|
635
|
+
"adapter": adapter,
|
|
636
|
+
"db_type": db_type,
|
|
637
|
+
"version": version,
|
|
638
|
+
"info": conn_info
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
self._refresh_connections()
|
|
642
|
+
self.db.set_setting("last_connection", name)
|
|
643
|
+
version_str = f" v{version}" if version else ""
|
|
644
|
+
self.statusbar.config(text=f"Connected to {name}{version_str}")
|
|
645
|
+
|
|
646
|
+
# Restore any pending tabs for this connection
|
|
647
|
+
if name in self._pending_tabs:
|
|
648
|
+
self.conn_tree.selection_set(name)
|
|
649
|
+
for tab in self._pending_tabs[name]:
|
|
650
|
+
if tab["tab_type"] == "sql":
|
|
651
|
+
self._new_sql_tab(initial_sql=tab.get("tab_data", ""))
|
|
652
|
+
elif tab["tab_type"] == "spool":
|
|
653
|
+
self._new_spool_tab(initial_user=tab.get("tab_data", "*CURRENT"))
|
|
654
|
+
del self._pending_tabs[name]
|
|
655
|
+
|
|
656
|
+
# Select the first tab and restore layout
|
|
657
|
+
if self.notebook.tabs():
|
|
658
|
+
self.notebook.select(0)
|
|
659
|
+
self.root.after(100, self._restore_layout)
|
|
660
|
+
|
|
661
|
+
def _on_connect_error(self, name, error):
|
|
662
|
+
"""Handle connection error (main thread)."""
|
|
663
|
+
self._connecting.discard(name)
|
|
664
|
+
# Remove any pending tabs for this failed connection
|
|
665
|
+
self._pending_tabs.pop(name, None)
|
|
666
|
+
self.statusbar.config(text=f"Connection to {name} failed")
|
|
667
|
+
tk.messagebox.showerror("Connection Error", f"{name}: {error}")
|
|
668
|
+
|
|
669
|
+
def _disconnect_selected(self):
|
|
670
|
+
"""Disconnect the selected connection."""
|
|
671
|
+
name = self._get_selected_connection()
|
|
672
|
+
if not name or name not in self.connections:
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
self.connections[name]["conn"].close()
|
|
676
|
+
del self.connections[name]
|
|
677
|
+
self._refresh_connections()
|
|
678
|
+
self.statusbar.config(text=f"Disconnected from {name}")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _count_tabs(self, conn_name, tab_type):
|
|
682
|
+
"""Count existing tabs of a given type for a connection."""
|
|
683
|
+
count = 0
|
|
684
|
+
for tab_id in self.notebook.tabs():
|
|
685
|
+
try:
|
|
686
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
687
|
+
if getattr(tab_frame, "conn_name", None) == conn_name and \
|
|
688
|
+
getattr(tab_frame, "tab_type", None) == tab_type:
|
|
689
|
+
count += 1
|
|
690
|
+
except Exception:
|
|
691
|
+
pass
|
|
692
|
+
return count
|
|
693
|
+
|
|
694
|
+
def _new_sql_tab(self, initial_sql=""):
|
|
695
|
+
"""Create a new SQL tab for the selected connection."""
|
|
696
|
+
name = self._get_selected_connection()
|
|
697
|
+
if not name or name not in self.connections:
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
self.tab_count += 1
|
|
701
|
+
conn_data = self.connections[name]
|
|
702
|
+
|
|
703
|
+
# Create tab frame with close capability
|
|
704
|
+
tab_frame = ttk.Frame(self.notebook)
|
|
705
|
+
sql_tab = SQLTab(
|
|
706
|
+
tab_frame, self, conn_data["conn"], name,
|
|
707
|
+
conn_data["version"], conn_data["adapter"]
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# Set initial SQL if provided
|
|
711
|
+
if initial_sql:
|
|
712
|
+
sql_tab.set_sql(initial_sql)
|
|
713
|
+
|
|
714
|
+
# Generate tab title with number if duplicates exist
|
|
715
|
+
existing_count = self._count_tabs(name, "sql")
|
|
716
|
+
if existing_count > 0:
|
|
717
|
+
tab_title = f"{name} SQL ({existing_count})"
|
|
718
|
+
else:
|
|
719
|
+
tab_title = f"{name} SQL"
|
|
720
|
+
|
|
721
|
+
self.notebook.add(tab_frame, text=tab_title)
|
|
722
|
+
self.notebook.select(tab_frame)
|
|
723
|
+
|
|
724
|
+
# Store reference for cleanup
|
|
725
|
+
tab_frame.sql_tab = sql_tab
|
|
726
|
+
tab_frame.conn_name = name
|
|
727
|
+
tab_frame.tab_type = "sql"
|
|
728
|
+
|
|
729
|
+
# Apply theme to new widgets
|
|
730
|
+
self._apply_theme_to_widgets()
|
|
731
|
+
|
|
732
|
+
def _new_spool_tab(self, initial_user="*CURRENT"):
|
|
733
|
+
"""Create a new Spool Files tab for the selected connection (IBM i only)."""
|
|
734
|
+
name = self._get_selected_connection()
|
|
735
|
+
if not name or name not in self.connections:
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
conn_data = self.connections[name]
|
|
739
|
+
|
|
740
|
+
# Spool files only available for IBM i
|
|
741
|
+
if not conn_data["adapter"].supports_spool:
|
|
742
|
+
tk.messagebox.showinfo("Not Available", "Spool files are only available for IBM i connections.")
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
self.tab_count += 1
|
|
746
|
+
|
|
747
|
+
tab_frame = ttk.Frame(self.notebook)
|
|
748
|
+
spool_tab = SpoolTab(tab_frame, self, conn_data["conn"], name, conn_data["version"])
|
|
749
|
+
|
|
750
|
+
# Set initial user if provided
|
|
751
|
+
if initial_user and initial_user != "*CURRENT":
|
|
752
|
+
spool_tab.set_user(initial_user)
|
|
753
|
+
|
|
754
|
+
# Generate tab title with number if duplicates exist
|
|
755
|
+
existing_count = self._count_tabs(name, "spool")
|
|
756
|
+
if existing_count > 0:
|
|
757
|
+
tab_title = f"{name} SPLF ({existing_count})"
|
|
758
|
+
else:
|
|
759
|
+
tab_title = f"{name} SPLF"
|
|
760
|
+
|
|
761
|
+
self.notebook.add(tab_frame, text=tab_title)
|
|
762
|
+
self.notebook.select(tab_frame)
|
|
763
|
+
|
|
764
|
+
tab_frame.spool_tab = spool_tab
|
|
765
|
+
tab_frame.conn_name = name
|
|
766
|
+
tab_frame.tab_type = "spool"
|
|
767
|
+
|
|
768
|
+
# Apply theme to new widgets
|
|
769
|
+
self._apply_theme_to_widgets()
|
|
770
|
+
|
|
771
|
+
def _close_tab(self, tab_frame):
|
|
772
|
+
"""Close a tab."""
|
|
773
|
+
try:
|
|
774
|
+
self.notebook.forget(tab_frame)
|
|
775
|
+
except Exception:
|
|
776
|
+
pass
|
|
777
|
+
|
|
778
|
+
def _close_tab_middle_click(self, event):
|
|
779
|
+
"""Close tab on middle click."""
|
|
780
|
+
try:
|
|
781
|
+
clicked_tab = self.notebook.tk.call(self.notebook._w, "identify", "tab", event.x, event.y)
|
|
782
|
+
if clicked_tab != "":
|
|
783
|
+
tab_frame = self.notebook.nametowidget(self.notebook.tabs()[int(clicked_tab)])
|
|
784
|
+
self._close_tab(tab_frame)
|
|
785
|
+
except Exception:
|
|
786
|
+
pass
|
|
787
|
+
|
|
788
|
+
def _show_tab_menu(self, event):
|
|
789
|
+
"""Show context menu on tab right-click."""
|
|
790
|
+
try:
|
|
791
|
+
clicked_tab = self.notebook.tk.call(self.notebook._w, "identify", "tab", event.x, event.y)
|
|
792
|
+
if clicked_tab != "":
|
|
793
|
+
self.notebook.select(int(clicked_tab))
|
|
794
|
+
self.tab_menu.post(event.x_root, event.y_root)
|
|
795
|
+
except Exception:
|
|
796
|
+
pass
|
|
797
|
+
|
|
798
|
+
def _close_current_tab(self):
|
|
799
|
+
"""Close the currently selected tab."""
|
|
800
|
+
try:
|
|
801
|
+
current = self.notebook.select()
|
|
802
|
+
if current:
|
|
803
|
+
self.notebook.forget(current)
|
|
804
|
+
except Exception:
|
|
805
|
+
pass
|
|
806
|
+
|
|
807
|
+
def _tab_drag_start(self, event):
|
|
808
|
+
"""Start dragging a tab."""
|
|
809
|
+
try:
|
|
810
|
+
clicked_tab = self.notebook.tk.call(self.notebook._w, "identify", "tab", event.x, event.y)
|
|
811
|
+
if clicked_tab != "":
|
|
812
|
+
self._drag_start_index = int(clicked_tab)
|
|
813
|
+
else:
|
|
814
|
+
self._drag_start_index = None
|
|
815
|
+
except Exception:
|
|
816
|
+
self._drag_start_index = None
|
|
817
|
+
|
|
818
|
+
def _tab_drag_motion(self, event):
|
|
819
|
+
"""Handle tab drag motion."""
|
|
820
|
+
if self._drag_start_index is None:
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
try:
|
|
824
|
+
target_tab = self.notebook.tk.call(self.notebook._w, "identify", "tab", event.x, event.y)
|
|
825
|
+
if target_tab == "" or int(target_tab) == self._drag_start_index:
|
|
826
|
+
return
|
|
827
|
+
|
|
828
|
+
target_index = int(target_tab)
|
|
829
|
+
tabs = self.notebook.tabs()
|
|
830
|
+
|
|
831
|
+
# Move the tab
|
|
832
|
+
tab_to_move = tabs[self._drag_start_index]
|
|
833
|
+
self.notebook.insert(target_index, tab_to_move)
|
|
834
|
+
self._drag_start_index = target_index
|
|
835
|
+
except Exception:
|
|
836
|
+
pass
|
|
837
|
+
|
|
838
|
+
def _tab_drag_end(self, event):
|
|
839
|
+
"""End tab dragging."""
|
|
840
|
+
self._drag_start_index = None
|
|
841
|
+
|
|
842
|
+
def _new_connection(self):
|
|
843
|
+
"""Show dialog to create new connection."""
|
|
844
|
+
dialog = ConnectionDialog(self.root, self.db, app=self)
|
|
845
|
+
self.root.wait_window(dialog.top)
|
|
846
|
+
self._refresh_connections()
|
|
847
|
+
|
|
848
|
+
def _edit_connection(self):
|
|
849
|
+
"""Edit the selected connection."""
|
|
850
|
+
old_name = self._get_selected_connection()
|
|
851
|
+
if old_name:
|
|
852
|
+
dialog = ConnectionDialog(self.root, self.db, edit_name=old_name, app=self)
|
|
853
|
+
self.root.wait_window(dialog.top)
|
|
854
|
+
self._refresh_connections()
|
|
855
|
+
self._update_tab_names(old_name)
|
|
856
|
+
|
|
857
|
+
def _update_tab_names(self, old_name):
|
|
858
|
+
"""Update tab names if connection was renamed."""
|
|
859
|
+
# Check if the old_name still exists - if not, it was renamed
|
|
860
|
+
conn = self.db.get_connection(old_name)
|
|
861
|
+
if conn:
|
|
862
|
+
# Name didn't change
|
|
863
|
+
return
|
|
864
|
+
|
|
865
|
+
# Find the new name by checking what connection was just edited
|
|
866
|
+
if old_name not in self.connections:
|
|
867
|
+
return
|
|
868
|
+
|
|
869
|
+
# Get the host from our active connection
|
|
870
|
+
conn_data = self.connections[old_name]
|
|
871
|
+
old_host = conn_data.get("info", {}).get("host", "")
|
|
872
|
+
|
|
873
|
+
# Look for connection with same host (the renamed one)
|
|
874
|
+
new_name = None
|
|
875
|
+
for c in self.db.get_connections():
|
|
876
|
+
full_conn = self.db.get_connection(c["name"])
|
|
877
|
+
if full_conn and full_conn["host"] == old_host:
|
|
878
|
+
new_name = c["name"]
|
|
879
|
+
break
|
|
880
|
+
|
|
881
|
+
if not new_name or new_name == old_name:
|
|
882
|
+
return
|
|
883
|
+
|
|
884
|
+
# Update the connections dict
|
|
885
|
+
self.connections[new_name] = self.connections.pop(old_name)
|
|
886
|
+
|
|
887
|
+
# Update all tabs with the old connection name
|
|
888
|
+
for tab_id in self.notebook.tabs():
|
|
889
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
890
|
+
if getattr(tab_frame, "conn_name", None) == old_name:
|
|
891
|
+
tab_frame.conn_name = new_name
|
|
892
|
+
tab_type = getattr(tab_frame, "tab_type", "")
|
|
893
|
+
# Preserve any existing number suffix
|
|
894
|
+
current_text = self.notebook.tab(tab_frame, "text")
|
|
895
|
+
suffix = ""
|
|
896
|
+
if "(" in current_text and current_text.endswith(")"):
|
|
897
|
+
suffix = " " + current_text[current_text.rfind("("):]
|
|
898
|
+
if tab_type == "sql":
|
|
899
|
+
self.notebook.tab(tab_frame, text=f"{new_name} SQL{suffix}")
|
|
900
|
+
elif tab_type == "spool":
|
|
901
|
+
self.notebook.tab(tab_frame, text=f"{new_name} SPLF{suffix}")
|
|
902
|
+
|
|
903
|
+
# Update connection tree display
|
|
904
|
+
self._refresh_connections()
|
|
905
|
+
# Re-mark as connected
|
|
906
|
+
for item in self.conn_tree.get_children():
|
|
907
|
+
if self.conn_tree.item(item, "text") == new_name:
|
|
908
|
+
self.conn_tree.item(item, values=("(connected)",))
|
|
909
|
+
break
|
|
910
|
+
|
|
911
|
+
# Update last_connection setting if it was the renamed one
|
|
912
|
+
if self.db.get_setting("last_connection") == old_name:
|
|
913
|
+
self.db.set_setting("last_connection", new_name)
|
|
914
|
+
|
|
915
|
+
# Save tabs immediately to persist the name change
|
|
916
|
+
self._save_tabs()
|
|
917
|
+
|
|
918
|
+
def _delete_connection(self):
|
|
919
|
+
"""Delete the selected connection."""
|
|
920
|
+
name = self._get_selected_connection()
|
|
921
|
+
if not name:
|
|
922
|
+
return
|
|
923
|
+
|
|
924
|
+
if name in self.connections:
|
|
925
|
+
tk.messagebox.showwarning("Connected", "Disconnect before deleting.")
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
if tk.messagebox.askyesno("Confirm Delete", f"Delete connection '{name}'?"):
|
|
929
|
+
conn = self.db.get_connection(name)
|
|
930
|
+
if conn:
|
|
931
|
+
self.db.delete_connection(conn["id"])
|
|
932
|
+
self._refresh_connections()
|
|
933
|
+
|
|
934
|
+
def _show_about(self):
|
|
935
|
+
tk.messagebox.showinfo("About", "IBM i Utility\nVersion 0.2")
|
|
936
|
+
|
|
937
|
+
def _show_settings(self):
|
|
938
|
+
"""Show settings dialog."""
|
|
939
|
+
settings_win = tk.Toplevel(self.root)
|
|
940
|
+
settings_win.title("Settings")
|
|
941
|
+
settings_win.transient(self.root)
|
|
942
|
+
settings_win.grab_set()
|
|
943
|
+
|
|
944
|
+
# Center on parent
|
|
945
|
+
settings_win.geometry("300x150")
|
|
946
|
+
settings_win.update_idletasks()
|
|
947
|
+
x = self.root.winfo_x() + (self.root.winfo_width() - 300) // 2
|
|
948
|
+
y = self.root.winfo_y() + (self.root.winfo_height() - 150) // 2
|
|
949
|
+
settings_win.geometry(f"+{x}+{y}")
|
|
950
|
+
|
|
951
|
+
# Apply dark mode
|
|
952
|
+
is_dark = self.dark_mode_var.get()
|
|
953
|
+
if is_dark:
|
|
954
|
+
settings_win.configure(bg="#2b2b2b")
|
|
955
|
+
fg = "#a9b7c6"
|
|
956
|
+
entry_bg = "#313335"
|
|
957
|
+
else:
|
|
958
|
+
fg = "#000000"
|
|
959
|
+
entry_bg = "#ffffff"
|
|
960
|
+
|
|
961
|
+
# Font size setting
|
|
962
|
+
font_frame = ttk.Frame(settings_win)
|
|
963
|
+
font_frame.pack(fill=tk.X, padx=20, pady=20)
|
|
964
|
+
|
|
965
|
+
ttk.Label(font_frame, text="Font Size:").pack(side=tk.LEFT)
|
|
966
|
+
|
|
967
|
+
font_size_var = tk.StringVar(value=str(self.font_size))
|
|
968
|
+
font_spin = ttk.Spinbox(font_frame, from_=6, to=24, width=5,
|
|
969
|
+
textvariable=font_size_var)
|
|
970
|
+
font_spin.pack(side=tk.LEFT, padx=(10, 0))
|
|
971
|
+
|
|
972
|
+
# Buttons
|
|
973
|
+
btn_frame = ttk.Frame(settings_win)
|
|
974
|
+
btn_frame.pack(fill=tk.X, padx=20, pady=10)
|
|
975
|
+
|
|
976
|
+
def apply_settings():
|
|
977
|
+
try:
|
|
978
|
+
new_size = int(font_size_var.get())
|
|
979
|
+
if 6 <= new_size <= 24:
|
|
980
|
+
self.font_size = new_size
|
|
981
|
+
self.db.set_setting("font_size", str(new_size))
|
|
982
|
+
self._apply_font_size()
|
|
983
|
+
settings_win.destroy()
|
|
984
|
+
else:
|
|
985
|
+
tk.messagebox.showwarning("Invalid", "Font size must be between 6 and 24")
|
|
986
|
+
except ValueError:
|
|
987
|
+
tk.messagebox.showwarning("Invalid", "Please enter a valid number")
|
|
988
|
+
|
|
989
|
+
def cancel():
|
|
990
|
+
settings_win.destroy()
|
|
991
|
+
|
|
992
|
+
ttk.Button(btn_frame, text="OK", command=apply_settings).pack(side=tk.RIGHT, padx=5)
|
|
993
|
+
ttk.Button(btn_frame, text="Cancel", command=cancel).pack(side=tk.RIGHT)
|
|
994
|
+
|
|
995
|
+
# Escape to close
|
|
996
|
+
settings_win.bind("<Escape>", lambda e: settings_win.destroy())
|
|
997
|
+
settings_win.bind("<Return>", lambda e: apply_settings())
|
|
998
|
+
|
|
999
|
+
def _apply_font_size(self):
|
|
1000
|
+
"""Apply font size to all UI elements globally."""
|
|
1001
|
+
# Update all named fonts in tkinter
|
|
1002
|
+
for font_name in ["TkDefaultFont", "TkTextFont", "TkMenuFont",
|
|
1003
|
+
"TkHeadingFont", "TkCaptionFont", "TkSmallCaptionFont",
|
|
1004
|
+
"TkIconFont", "TkTooltipFont"]:
|
|
1005
|
+
try:
|
|
1006
|
+
font = tkfont.nametofont(font_name)
|
|
1007
|
+
font.configure(size=self.font_size)
|
|
1008
|
+
except Exception:
|
|
1009
|
+
pass
|
|
1010
|
+
|
|
1011
|
+
# Update fixed font separately (for code/SQL)
|
|
1012
|
+
try:
|
|
1013
|
+
fixed_font = tkfont.nametofont("TkFixedFont")
|
|
1014
|
+
fixed_font.configure(size=self.font_size)
|
|
1015
|
+
except Exception:
|
|
1016
|
+
pass
|
|
1017
|
+
|
|
1018
|
+
# Update ttk style for Treeview
|
|
1019
|
+
style = ttk.Style()
|
|
1020
|
+
rowheight = int(self.font_size * 1.8) # Scale row height with font
|
|
1021
|
+
style.configure("Treeview", rowheight=rowheight)
|
|
1022
|
+
|
|
1023
|
+
# Update SQL tab widgets and scale column widths
|
|
1024
|
+
scale = self.font_size / 10.0 # Base scale factor
|
|
1025
|
+
for tab_id in self.notebook.tabs():
|
|
1026
|
+
try:
|
|
1027
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
1028
|
+
if hasattr(tab_frame, 'sql_tab'):
|
|
1029
|
+
sql_tab = tab_frame.sql_tab
|
|
1030
|
+
sql_tab.scale_columns(scale)
|
|
1031
|
+
# Update stats text font
|
|
1032
|
+
if hasattr(sql_tab, 'stats_text'):
|
|
1033
|
+
sql_tab.stats_text.configure(font=("Courier", self.font_size))
|
|
1034
|
+
except Exception:
|
|
1035
|
+
pass
|
|
1036
|
+
|
|
1037
|
+
self.statusbar.config(text=f"Font size set to {self.font_size}")
|
|
1038
|
+
|
|
1039
|
+
def _toggle_dark_mode(self):
|
|
1040
|
+
"""Toggle dark mode on/off."""
|
|
1041
|
+
is_dark = self.dark_mode_var.get()
|
|
1042
|
+
self.db.set_setting("dark_mode", "1" if is_dark else "0")
|
|
1043
|
+
self._apply_theme()
|
|
1044
|
+
|
|
1045
|
+
def _apply_theme(self):
|
|
1046
|
+
"""Apply light or dark theme."""
|
|
1047
|
+
is_dark = self.dark_mode_var.get()
|
|
1048
|
+
style = ttk.Style()
|
|
1049
|
+
|
|
1050
|
+
if is_dark:
|
|
1051
|
+
# Darcula-inspired dark theme
|
|
1052
|
+
bg = "#2b2b2b"
|
|
1053
|
+
fg = "#a9b7c6"
|
|
1054
|
+
bg_light = "#313335"
|
|
1055
|
+
bg_dark = "#1e1e1e"
|
|
1056
|
+
select_bg = "#214283"
|
|
1057
|
+
border = "#3c3f41"
|
|
1058
|
+
|
|
1059
|
+
style.theme_use("clam")
|
|
1060
|
+
|
|
1061
|
+
style.configure(".", background=bg, foreground=fg, fieldbackground=bg_light,
|
|
1062
|
+
troughcolor=bg_dark, bordercolor=border, lightcolor=bg_light,
|
|
1063
|
+
darkcolor=bg_dark, insertcolor=fg)
|
|
1064
|
+
style.configure("TFrame", background=bg)
|
|
1065
|
+
style.configure("TLabel", background=bg, foreground=fg)
|
|
1066
|
+
style.configure("TLabelframe", background=bg, foreground=fg)
|
|
1067
|
+
style.configure("TLabelframe.Label", background=bg, foreground=fg)
|
|
1068
|
+
style.configure("TButton", background=bg_light, foreground=fg)
|
|
1069
|
+
style.configure("TEntry", fieldbackground=bg_light, foreground=fg, insertcolor=fg)
|
|
1070
|
+
style.configure("TCombobox", fieldbackground=bg_light, foreground=fg)
|
|
1071
|
+
style.configure("TNotebook", background=bg, foreground=fg)
|
|
1072
|
+
style.configure("TNotebook.Tab", background=bg_light, foreground=fg, padding=[8, 2])
|
|
1073
|
+
style.configure("TPanedwindow", background=bg)
|
|
1074
|
+
style.configure("TScrollbar", background=bg_light, troughcolor=bg_dark)
|
|
1075
|
+
style.configure("Treeview", background=bg_light, foreground=fg, fieldbackground=bg_light)
|
|
1076
|
+
style.configure("Treeview.Heading", background=bg, foreground=fg)
|
|
1077
|
+
|
|
1078
|
+
style.map("TButton", background=[("active", bg_light)])
|
|
1079
|
+
style.map("TNotebook.Tab", background=[("selected", bg), ("active", bg_light)])
|
|
1080
|
+
style.map("Treeview", background=[("selected", select_bg)], foreground=[("selected", fg)])
|
|
1081
|
+
style.map("TCombobox", fieldbackground=[("readonly", bg_light)])
|
|
1082
|
+
|
|
1083
|
+
self.root.configure(bg=bg)
|
|
1084
|
+
else:
|
|
1085
|
+
# Light theme
|
|
1086
|
+
style.theme_use("clam")
|
|
1087
|
+
|
|
1088
|
+
bg = "#d9d9d9"
|
|
1089
|
+
fg = "#000000"
|
|
1090
|
+
bg_light = "#ffffff"
|
|
1091
|
+
select_bg = "#4a6984"
|
|
1092
|
+
border = "#9e9e9e"
|
|
1093
|
+
|
|
1094
|
+
style.configure(".", background=bg, foreground=fg, fieldbackground=bg_light,
|
|
1095
|
+
troughcolor="#c3c3c3", bordercolor=border,
|
|
1096
|
+
lightcolor="#ededed", darkcolor="#cfcfcf", insertcolor=fg)
|
|
1097
|
+
style.configure("TFrame", background=bg)
|
|
1098
|
+
style.configure("TLabel", background=bg, foreground=fg)
|
|
1099
|
+
style.configure("TLabelframe", background=bg, foreground=fg)
|
|
1100
|
+
style.configure("TLabelframe.Label", background=bg, foreground=fg)
|
|
1101
|
+
style.configure("TButton", background="#e1e1e1", foreground=fg)
|
|
1102
|
+
style.configure("TEntry", fieldbackground=bg_light, foreground=fg, insertcolor=fg)
|
|
1103
|
+
style.configure("TCombobox", fieldbackground=bg_light, foreground=fg)
|
|
1104
|
+
style.configure("TNotebook", background=bg, foreground=fg)
|
|
1105
|
+
style.configure("TNotebook.Tab", background="#c3c3c3", foreground=fg, padding=[8, 2])
|
|
1106
|
+
style.configure("TPanedwindow", background=bg)
|
|
1107
|
+
style.configure("TScrollbar", background="#c3c3c3", troughcolor=bg)
|
|
1108
|
+
style.configure("Treeview", background=bg_light, foreground=fg, fieldbackground=bg_light)
|
|
1109
|
+
style.configure("Treeview.Heading", background=bg, foreground=fg)
|
|
1110
|
+
style.configure("TCheckbutton", background=bg, foreground=fg)
|
|
1111
|
+
|
|
1112
|
+
style.map("TButton", background=[("active", "#ececec")])
|
|
1113
|
+
style.map("TNotebook.Tab", background=[("selected", bg)])
|
|
1114
|
+
style.map("Treeview", background=[("selected", select_bg)], foreground=[("selected", "#ffffff")])
|
|
1115
|
+
style.map("TCombobox", fieldbackground=[("readonly", bg_light)])
|
|
1116
|
+
|
|
1117
|
+
self.root.configure(bg=bg)
|
|
1118
|
+
|
|
1119
|
+
# Apply to non-ttk widgets
|
|
1120
|
+
self._apply_theme_to_widgets()
|
|
1121
|
+
|
|
1122
|
+
def _apply_theme_to_widgets(self):
|
|
1123
|
+
"""Apply theme to non-ttk widgets (Text, Listbox, Menu)."""
|
|
1124
|
+
is_dark = self.dark_mode_var.get()
|
|
1125
|
+
|
|
1126
|
+
if is_dark:
|
|
1127
|
+
bg = "#2b2b2b"
|
|
1128
|
+
fg = "#a9b7c6"
|
|
1129
|
+
text_bg = "#313335"
|
|
1130
|
+
select_bg = "#214283"
|
|
1131
|
+
else:
|
|
1132
|
+
bg = "#f0f0f0"
|
|
1133
|
+
fg = "#000000"
|
|
1134
|
+
text_bg = "#ffffff"
|
|
1135
|
+
select_bg = "#0078d4"
|
|
1136
|
+
|
|
1137
|
+
self._configure_widgets_recursive(self.root, text_bg, fg, select_bg, bg)
|
|
1138
|
+
|
|
1139
|
+
def _configure_widgets_recursive(self, widget, bg, fg, select_bg, menu_bg):
|
|
1140
|
+
"""Recursively configure non-ttk widgets."""
|
|
1141
|
+
widget_class = widget.winfo_class()
|
|
1142
|
+
|
|
1143
|
+
try:
|
|
1144
|
+
if widget_class == "Text":
|
|
1145
|
+
widget.configure(bg=bg, fg=fg, insertbackground=fg,
|
|
1146
|
+
selectbackground=select_bg, selectforeground=fg)
|
|
1147
|
+
elif widget_class == "Listbox":
|
|
1148
|
+
widget.configure(bg=bg, fg=fg,
|
|
1149
|
+
selectbackground=select_bg, selectforeground=fg)
|
|
1150
|
+
elif widget_class == "Menu":
|
|
1151
|
+
widget.configure(bg=menu_bg, fg=fg,
|
|
1152
|
+
activebackground=select_bg, activeforeground=fg)
|
|
1153
|
+
except tk.TclError:
|
|
1154
|
+
pass
|
|
1155
|
+
|
|
1156
|
+
for child in widget.winfo_children():
|
|
1157
|
+
self._configure_widgets_recursive(child, bg, fg, select_bg, menu_bg)
|
|
1158
|
+
|
|
1159
|
+
def _restore_session(self):
|
|
1160
|
+
"""Restore connections and tabs from last session (non-blocking)."""
|
|
1161
|
+
# Get saved tabs to know which connections to restore
|
|
1162
|
+
saved_tabs = self.db.get_saved_tabs()
|
|
1163
|
+
|
|
1164
|
+
# Group tabs by connection
|
|
1165
|
+
tabs_by_conn = {}
|
|
1166
|
+
for tab in saved_tabs:
|
|
1167
|
+
conn_name = tab["connection_name"]
|
|
1168
|
+
if conn_name not in tabs_by_conn:
|
|
1169
|
+
tabs_by_conn[conn_name] = []
|
|
1170
|
+
tabs_by_conn[conn_name].append(tab)
|
|
1171
|
+
|
|
1172
|
+
connections_needed = set(tabs_by_conn.keys())
|
|
1173
|
+
|
|
1174
|
+
# Also restore last connection if set
|
|
1175
|
+
last_conn = self.db.get_setting("last_connection")
|
|
1176
|
+
if last_conn:
|
|
1177
|
+
connections_needed.add(last_conn)
|
|
1178
|
+
|
|
1179
|
+
# Connect to needed connections (async)
|
|
1180
|
+
available = [c["name"] for c in self.db.get_connections()]
|
|
1181
|
+
connecting_count = 0
|
|
1182
|
+
for conn_name in connections_needed:
|
|
1183
|
+
if conn_name in available and conn_name not in self.connections:
|
|
1184
|
+
# Queue pending tabs for this connection
|
|
1185
|
+
if conn_name in tabs_by_conn:
|
|
1186
|
+
self._pending_tabs[conn_name] = tabs_by_conn[conn_name]
|
|
1187
|
+
|
|
1188
|
+
self.conn_tree.selection_set(conn_name)
|
|
1189
|
+
self._connect_selected()
|
|
1190
|
+
connecting_count += 1
|
|
1191
|
+
|
|
1192
|
+
if connecting_count > 0:
|
|
1193
|
+
self.statusbar.config(text=f"Restoring {connecting_count} connection(s)...")
|
|
1194
|
+
else:
|
|
1195
|
+
# No connections to wait for, restore layout now
|
|
1196
|
+
self.root.after(100, self._restore_layout)
|
|
1197
|
+
|
|
1198
|
+
def _restore_geometry(self):
|
|
1199
|
+
"""Restore window geometry from saved settings."""
|
|
1200
|
+
default_geometry = "1200x800"
|
|
1201
|
+
saved = self.db.get_setting("window_geometry", default_geometry)
|
|
1202
|
+
|
|
1203
|
+
try:
|
|
1204
|
+
self.root.geometry(saved)
|
|
1205
|
+
self.root.update_idletasks()
|
|
1206
|
+
|
|
1207
|
+
if not self._is_visible_on_screen():
|
|
1208
|
+
self.root.geometry(default_geometry)
|
|
1209
|
+
self._center_window()
|
|
1210
|
+
except Exception:
|
|
1211
|
+
self.root.geometry(default_geometry)
|
|
1212
|
+
|
|
1213
|
+
def _is_visible_on_screen(self):
|
|
1214
|
+
"""Check if at least part of the window is visible on screen."""
|
|
1215
|
+
x = self.root.winfo_x()
|
|
1216
|
+
y = self.root.winfo_y()
|
|
1217
|
+
w = self.root.winfo_width()
|
|
1218
|
+
h = self.root.winfo_height()
|
|
1219
|
+
|
|
1220
|
+
screen_w = self.root.winfo_screenwidth()
|
|
1221
|
+
screen_h = self.root.winfo_screenheight()
|
|
1222
|
+
|
|
1223
|
+
min_visible = 100
|
|
1224
|
+
visible_x = x + w > min_visible and x < screen_w - min_visible
|
|
1225
|
+
visible_y = y + h > min_visible and y < screen_h - min_visible
|
|
1226
|
+
|
|
1227
|
+
return visible_x and visible_y
|
|
1228
|
+
|
|
1229
|
+
def _center_window(self):
|
|
1230
|
+
"""Center window on screen."""
|
|
1231
|
+
self.root.update_idletasks()
|
|
1232
|
+
w = self.root.winfo_width()
|
|
1233
|
+
h = self.root.winfo_height()
|
|
1234
|
+
screen_w = self.root.winfo_screenwidth()
|
|
1235
|
+
screen_h = self.root.winfo_screenheight()
|
|
1236
|
+
x = (screen_w - w) // 2
|
|
1237
|
+
y = (screen_h - h) // 2
|
|
1238
|
+
self.root.geometry(f"{w}x{h}+{x}+{y}")
|
|
1239
|
+
|
|
1240
|
+
def _save_geometry(self):
|
|
1241
|
+
"""Save current window geometry."""
|
|
1242
|
+
geometry = self.root.geometry()
|
|
1243
|
+
self.db.set_setting("window_geometry", geometry)
|
|
1244
|
+
|
|
1245
|
+
def _save_layout(self):
|
|
1246
|
+
"""Save paned window sash positions."""
|
|
1247
|
+
try:
|
|
1248
|
+
# Main paned window (connections | tabs)
|
|
1249
|
+
sash_pos = self.main_paned.sashpos(0)
|
|
1250
|
+
self.db.set_setting("layout_main_sash", str(sash_pos))
|
|
1251
|
+
except Exception:
|
|
1252
|
+
pass
|
|
1253
|
+
|
|
1254
|
+
# Save tab-level layouts (use last found position for each type)
|
|
1255
|
+
sql_sash = None
|
|
1256
|
+
spool_sash = None
|
|
1257
|
+
|
|
1258
|
+
for tab_id in self.notebook.tabs():
|
|
1259
|
+
try:
|
|
1260
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
1261
|
+
if hasattr(tab_frame, 'sql_tab') and hasattr(tab_frame.sql_tab, 'paned'):
|
|
1262
|
+
sql_sash = tab_frame.sql_tab.paned.sashpos(0)
|
|
1263
|
+
elif hasattr(tab_frame, 'spool_tab') and hasattr(tab_frame.spool_tab, 'paned'):
|
|
1264
|
+
spool_sash = tab_frame.spool_tab.paned.sashpos(0)
|
|
1265
|
+
except Exception:
|
|
1266
|
+
pass
|
|
1267
|
+
|
|
1268
|
+
if sql_sash is not None:
|
|
1269
|
+
self.db.set_setting("layout_sql_sash", str(sql_sash))
|
|
1270
|
+
if spool_sash is not None:
|
|
1271
|
+
self.db.set_setting("layout_spool_sash", str(spool_sash))
|
|
1272
|
+
|
|
1273
|
+
def _restore_layout(self):
|
|
1274
|
+
"""Restore paned window sash positions."""
|
|
1275
|
+
try:
|
|
1276
|
+
sash_pos = self.db.get_setting("layout_main_sash")
|
|
1277
|
+
if sash_pos:
|
|
1278
|
+
self.root.update_idletasks()
|
|
1279
|
+
self.main_paned.sashpos(0, int(sash_pos))
|
|
1280
|
+
except Exception:
|
|
1281
|
+
pass
|
|
1282
|
+
|
|
1283
|
+
# Restore tab-level layouts (only if saved value is reasonable)
|
|
1284
|
+
sql_sash = self.db.get_setting("layout_sql_sash")
|
|
1285
|
+
spool_sash = self.db.get_setting("layout_spool_sash")
|
|
1286
|
+
|
|
1287
|
+
# Ignore sash positions that are too small (would hide the pane)
|
|
1288
|
+
MIN_SASH_POS = 50
|
|
1289
|
+
if sql_sash and int(sql_sash) < MIN_SASH_POS:
|
|
1290
|
+
sql_sash = None
|
|
1291
|
+
if spool_sash and int(spool_sash) < MIN_SASH_POS:
|
|
1292
|
+
spool_sash = None
|
|
1293
|
+
|
|
1294
|
+
for tab_id in self.notebook.tabs():
|
|
1295
|
+
try:
|
|
1296
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
1297
|
+
if sql_sash and hasattr(tab_frame, 'sql_tab') and hasattr(tab_frame.sql_tab, 'paned'):
|
|
1298
|
+
self.root.update_idletasks()
|
|
1299
|
+
tab_frame.sql_tab.paned.sashpos(0, int(sql_sash))
|
|
1300
|
+
elif spool_sash and hasattr(tab_frame, 'spool_tab') and hasattr(tab_frame.spool_tab, 'paned'):
|
|
1301
|
+
self.root.update_idletasks()
|
|
1302
|
+
tab_frame.spool_tab.paned.sashpos(0, int(spool_sash))
|
|
1303
|
+
except Exception:
|
|
1304
|
+
pass
|
|
1305
|
+
|
|
1306
|
+
def _reset_layout(self):
|
|
1307
|
+
"""Reset layout to defaults."""
|
|
1308
|
+
# Clear saved layout settings
|
|
1309
|
+
self.db.set_setting("layout_main_sash", "")
|
|
1310
|
+
self.db.set_setting("layout_sql_sash", "")
|
|
1311
|
+
self.db.set_setting("layout_spool_sash", "")
|
|
1312
|
+
|
|
1313
|
+
# Reset font size to default
|
|
1314
|
+
self.font_size = 10
|
|
1315
|
+
self.db.set_setting("font_size", "10")
|
|
1316
|
+
self._apply_font_size()
|
|
1317
|
+
|
|
1318
|
+
# Reset main paned to default (connections panel ~200px)
|
|
1319
|
+
self.root.update_idletasks()
|
|
1320
|
+
try:
|
|
1321
|
+
self.main_paned.sashpos(0, 200)
|
|
1322
|
+
except Exception:
|
|
1323
|
+
pass
|
|
1324
|
+
|
|
1325
|
+
# Reset any open tab layouts - need to select each tab for sash to apply
|
|
1326
|
+
current_tab = self.notebook.select()
|
|
1327
|
+
for tab_id in self.notebook.tabs():
|
|
1328
|
+
try:
|
|
1329
|
+
self.notebook.select(tab_id)
|
|
1330
|
+
self.root.update_idletasks()
|
|
1331
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
1332
|
+
if hasattr(tab_frame, 'sql_tab') and hasattr(tab_frame.sql_tab, 'paned'):
|
|
1333
|
+
# SQL tab: vertical split, 50/50
|
|
1334
|
+
paned_height = tab_frame.sql_tab.paned.winfo_height()
|
|
1335
|
+
tab_frame.sql_tab.paned.sashpos(0, paned_height // 2)
|
|
1336
|
+
elif hasattr(tab_frame, 'spool_tab') and hasattr(tab_frame.spool_tab, 'paned'):
|
|
1337
|
+
# Spool tab: horizontal split, 50/50
|
|
1338
|
+
paned_width = tab_frame.spool_tab.paned.winfo_width()
|
|
1339
|
+
tab_frame.spool_tab.paned.sashpos(0, paned_width // 2)
|
|
1340
|
+
except Exception:
|
|
1341
|
+
pass
|
|
1342
|
+
# Restore original tab selection
|
|
1343
|
+
if current_tab:
|
|
1344
|
+
self.notebook.select(current_tab)
|
|
1345
|
+
|
|
1346
|
+
self.statusbar.config(text="Layout reset to defaults")
|
|
1347
|
+
|
|
1348
|
+
def _on_close(self):
|
|
1349
|
+
"""Handle window close event."""
|
|
1350
|
+
self._save_geometry()
|
|
1351
|
+
self._save_layout()
|
|
1352
|
+
self._save_tabs()
|
|
1353
|
+
# Close all connections
|
|
1354
|
+
for name, data in self.connections.items():
|
|
1355
|
+
try:
|
|
1356
|
+
data["conn"].close()
|
|
1357
|
+
except Exception:
|
|
1358
|
+
pass
|
|
1359
|
+
self.root.destroy()
|
|
1360
|
+
|
|
1361
|
+
def _save_tabs(self):
|
|
1362
|
+
"""Save current tab state."""
|
|
1363
|
+
tabs = []
|
|
1364
|
+
for tab_id in self.notebook.tabs():
|
|
1365
|
+
try:
|
|
1366
|
+
tab_frame = self.notebook.nametowidget(tab_id)
|
|
1367
|
+
tab_type = getattr(tab_frame, "tab_type", None)
|
|
1368
|
+
conn_name = getattr(tab_frame, "conn_name", None)
|
|
1369
|
+
|
|
1370
|
+
if tab_type and conn_name:
|
|
1371
|
+
data = ""
|
|
1372
|
+
if tab_type == "sql" and hasattr(tab_frame, "sql_tab"):
|
|
1373
|
+
data = tab_frame.sql_tab.get_sql()
|
|
1374
|
+
elif tab_type == "spool" and hasattr(tab_frame, "spool_tab"):
|
|
1375
|
+
data = tab_frame.spool_tab.get_user()
|
|
1376
|
+
|
|
1377
|
+
tabs.append({
|
|
1378
|
+
"type": tab_type,
|
|
1379
|
+
"connection": conn_name,
|
|
1380
|
+
"data": data
|
|
1381
|
+
})
|
|
1382
|
+
except Exception:
|
|
1383
|
+
pass
|
|
1384
|
+
|
|
1385
|
+
self.db.save_tabs(tabs)
|
|
1386
|
+
|
|
1387
|
+
def run(self):
|
|
1388
|
+
self.root.mainloop()
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def main():
|
|
1392
|
+
"""Entry point for SQLBench."""
|
|
1393
|
+
app = SQLBenchApp()
|
|
1394
|
+
app.run()
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
if __name__ == "__main__":
|
|
1398
|
+
main()
|