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/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()