From 745c0dfe4823b9e34a64c2bded563d2bcedc89de Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 5 Oct 2026 16:34:17 +0300 Subject: [PATCH] refactor(workspace): unify SQL workspaces into GenericSqlWorkspace (#1032) --- .../extensions/extension_sql_workspace.dart | 795 ++-------- lib/features/mysql/mysql_sql_workspace.dart | 1218 +++------------ .../postgresql/postgres_sql_workspace.dart | 1340 ++++------------- lib/features/sqlite/sqlite_sql_workspace.dart | 1109 +++----------- .../workspace/generic_sql_workspace.dart | 992 ++++++++++++ .../workspace/sql_execution_delegate.dart | 68 + lib/features/workspace/workspace.dart | 4 + 7 files changed, 1894 insertions(+), 3632 deletions(-) create mode 100644 lib/features/workspace/generic_sql_workspace.dart create mode 100644 lib/features/workspace/sql_execution_delegate.dart diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index ae175303..fee98198 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -1,17 +1,9 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; -import 'package:file_selector/file_selector.dart'; -import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; -import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; -import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; -import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; -import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/extensions/extension_driver_recovery_banner.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -19,11 +11,79 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Table/view selected in the sidebar tree of an extension connection. typedef ExtensionSelectedObject = ({String database, String name}); +/// Execution delegate for plugin/extension database drivers communicating via JSON-RPC. +class ExtensionSqlExecutionDelegate extends SqlExecutionDelegate { + ExtensionSqlExecutionDelegate({required this.connectionRow}); + + final ConnectionRow connectionRow; + + @override + bool get supportsTransactions => false; + + @override + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }) async { + final sw = Stopwatch()..start(); + final result = await ExtensionDriverSession.instance.query( + connectionRow, + sql, + limit: limit, + ); + sw.stop(); + final duration = result.elapsedMs != null + ? Duration(milliseconds: result.elapsedMs!) + : sw.elapsed; + + final isCapped = limit != null && result.rows.length >= limit; + String? statusMsg; + if (result.columns.isEmpty && result.rows.isEmpty) { + statusMsg = result.message ?? 'Command completed.'; + } else { + final elapsedStr = + result.elapsedMs != null ? ' in ${result.elapsedMs}ms' : ''; + statusMsg = isCapped + ? 'Showing first $limit row(s)$elapsedStr (result capped).' + : '${result.rows.length} row(s)$elapsedStr.'; + } + + return SqlExecutionResult( + columns: result.columns, + rows: result.rows, + elapsed: duration, + statusMessage: statusMsg, + isTruncated: isCapped, + ); + } + + @override + Future explainQuery(String sql) async => + throw UnsupportedError('EXPLAIN is not supported for generic extension drivers'); + + @override + Future cancelQuery() async {} + + @override + Future resolveTableSchema( + String userSql, + List columns, + ) async => + SqlResultGridSchema.none; + + @override + Future applyStagedMutations({ + required TableMutationPlan plan, + Duration? timeout, + }) async => + throw UnsupportedError('DML mutations are not supported for extension drivers'); + + @override + void dispose() {} +} + /// Ad-hoc SQL editor + results for extension database drivers (Block D). -/// -/// Executes queries through [ExtensionDriverSession] (`db.query` JSON-RPC). -/// When [selectedObject] is set, seeds and auto-runs a preview query so -/// clicking a table in the sidebar opens its data. class ExtensionSqlWorkspace extends material.StatefulWidget { const ExtensionSqlWorkspace({ super.key, @@ -41,20 +101,12 @@ class ExtensionSqlWorkspace extends material.StatefulWidget { _ExtensionSqlWorkspaceState(); } -class _ExtensionSqlWorkspaceState - extends material.State { - final List _sessions = []; - int _activeSessionIndex = 0; - int _nextSessionId = 1; - - SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; +class _ExtensionSqlWorkspaceState extends material.State { + late final ExtensionSqlExecutionDelegate _delegate; + final material.GlobalKey _workspaceKey = + material.GlobalKey(); bool _restartingDriver = false; - - int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; - int _resultMaxRows = kDefaultSqlResultMaxRows; - double _editorFontSize = kDefaultSqlEditorFontSize; - static const _previewRowLimit = 200; bool _isDriverError(String? err) { @@ -85,13 +137,22 @@ class _ExtensionSqlWorkspaceState ); setState(() { _restartingDriver = false; - _activeSession.error = null; + final ws = _workspaceKey.currentState; + if (ws != null) { + ws.activeSession.error = null; + } }); - await _execute(_activeSession); + final ws = _workspaceKey.currentState; + if (ws != null) { + await ws.execute(ws.activeSession); + } } catch (e) { if (!mounted) return; setState(() { - _activeSession.error = 'Driver restart failed: $e'; + final ws = _workspaceKey.currentState; + if (ws != null) { + ws.activeSession.error = 'Driver restart failed: $e'; + } _restartingDriver = false; }); showAppToast( @@ -105,139 +166,9 @@ class _ExtensionSqlWorkspaceState @override void initState() { super.initState(); - _sessions.add( - SqlQueryTabSession( - id: 'ext_tab_1', - title: widget.selectedObject?.name ?? 'Query 1', - initialSql: widget.initialSql, - ), - ); + _delegate = ExtensionSqlExecutionDelegate(connectionRow: widget.connectionRow); material.WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_loadWorkspaceSettings()); _applySelectedObject(); - _registerSqlEditorCommands(); - }); - } - - @override - void dispose() { - SqlEditorCommandBridge.instance - .unregister(connectionId: widget.connectionRow.id); - for (final s in _sessions) { - s.dispose(); - } - _sessions.clear(); - super.dispose(); - } - - void _registerSqlEditorCommands() { - if (!mounted) return; - SqlEditorCommandBridge.instance.register( - connectionId: widget.connectionRow.id, - onNew: _addNewTab, - onOpen: () => unawaited(_openSqlFile()), - onSave: () => unawaited(_saveSqlFile()), - onCloseTab: () { - if (_sessions.length > 1) { - unawaited(_closeTab(_activeSessionIndex)); - } - }, - onNextTab: _nextTab, - onPrevTab: _prevTab, - onFormat: () { - _activeSession.formatSql(); - setState(() {}); - }, - onClear: () { - _activeSession.clearSql(); - setState(() {}); - }, - onExecute: () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - onOpenWithContent: (sql, filePath, title) { - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: sql, - selection: material.TextSelection.collapsed(offset: sql.length), - ); - session.title = title; - session.filePath = filePath; - setState(() {}); - } else { - _addNewTab(initialSql: sql, title: title, filePath: filePath); - } - }, - ); - } - - void _addNewTab({String? initialSql, String? title, String? filePath}) { - setState(() { - _nextSessionId++; - final session = SqlQueryTabSession( - id: 'ext_tab_$_nextSessionId', - title: title ?? 'Query $_nextSessionId', - initialSql: initialSql, - filePath: filePath, - initialFraction: - _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.6, - ); - _sessions.add(session); - _activeSessionIndex = _sessions.length - 1; - }); - } - - Future _closeTab(int index) async { - if (index < 0 || index >= _sessions.length) return; - if (_sessions.length <= 1) return; - final session = _sessions[index]; - if (session.isDirty) { - final hasDirtyStaging = - session.stagingBuffer != null && session.stagingBuffer!.isDirty; - final hasUnsavedText = session.isModified || - (session.filePath == null && session.controller.text.trim().isNotEmpty); - final String message; - if (hasDirtyStaging && hasUnsavedText) { - message = - 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; - } else if (hasDirtyStaging) { - message = - 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; - } else { - message = - 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; - } - final confirmed = await showUnsavedTabChangesDialog( - context: context, - tabTitle: session.title, - message: message, - ); - if (confirmed != true) return; - } - if (!mounted) return; - setState(() { - _sessions.removeAt(index); - session.dispose(); - if (_activeSessionIndex >= _sessions.length) { - _activeSessionIndex = _sessions.length - 1; - } - }); - } - - void _nextTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = (_activeSessionIndex + 1) % _sessions.length; - }); - } - - void _prevTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = - (_activeSessionIndex - 1 + _sessions.length) % _sessions.length; }); } @@ -258,455 +189,56 @@ class _ExtensionSqlWorkspaceState if (obj == null) return; final sql = 'SELECT * FROM `${obj.database}`.`${obj.name}` LIMIT $_previewRowLimit'; - if (_activeSession.controller.text.trim().isEmpty && - _activeSession.rows.isEmpty) { - _activeSession.controller.value = material.TextEditingValue( + final ws = _workspaceKey.currentState; + if (ws == null) return; + + if (ws.activeSession.controller.text.trim().isEmpty && + ws.activeSession.rows.isEmpty) { + ws.activeSession.controller.value = material.TextEditingValue( text: sql, selection: material.TextSelection.collapsed(offset: sql.length), ); - _activeSession.title = obj.name; - setState(() {}); - unawaited(_execute(_activeSession)); + ws.activeSession.title = obj.name; + ws.invalidatePane(ws.activeSession); + ws.setState(() {}); + unawaited(ws.execute(ws.activeSession)); } else { - _addNewTab(initialSql: sql, title: obj.name); - unawaited(_execute(_activeSession)); - } - } - - Future _loadWorkspaceSettings() async { - final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); - final rows = await AppSettings.instance.getSqlResultMaxRows(); - final font = await AppSettings.instance.getSqlEditorFontSize(); - if (!mounted) return; - setState(() { - _historyMaxEntries = hist; - _resultMaxRows = rows; - _editorFontSize = font; - }); - } - - Future _execute([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - if (session.running) return; - final selection = session.controller.selection; - String userSql; - if (selection.isValid && !selection.isCollapsed) { - userSql = selection.textInside(session.controller.text).trim(); - } else { - userSql = session.controller.text.trim(); - } - if (userSql.isEmpty) return; - - final safeToProceed = await confirmDiscardTableEditsIfDirty( - context: context, - buffer: session.stagingBuffer, - tableTitle: session.title, - ); - if (!safeToProceed) return; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { - session.stagingBuffer?.dispose(); - session.stagingBuffer = null; - } - - final confirmDestructive = - await AppSettings.instance.getConfirmDestructiveOperations(); - if (confirmDestructive) { - final inspection = DestructiveSqlDetector.inspect(userSql); - if (inspection.isDestructive) { - if (!mounted) return; - final confirmed = await showDestructiveQueryDialog( - context: context, - result: inspection, - sql: userSql, - connectionName: widget.connectionRow.name, - ); - if (confirmed != true) return; - } - } - - setState(() { - session.running = true; - session.error = null; - session.columns = []; - session.rows = []; - session.statusLine = null; - }); - QueryaShellStatus.instance.beginBusy(message: 'Running query…'); - final sw = Stopwatch()..start(); - - try { - final result = await ExtensionDriverSession.instance.query( - widget.connectionRow, - userSql, - limit: _resultMaxRows, - ); - if (!mounted) return; - - setState(() { - session.columns = result.columns; - session.rows = result.rows; - if (result.columns.isEmpty && result.rows.isEmpty) { - session.statusLine = result.message ?? 'Command completed.'; - } else { - final elapsed = - result.elapsedMs != null ? ' in ${result.elapsedMs}ms' : ''; - final capped = result.rows.length >= _resultMaxRows; - session.statusLine = capped - ? 'Showing first $_resultMaxRows row(s)$elapsed (result capped).' - : '${result.rows.length} row(s)$elapsed.'; - } - session.running = false; - }); - sw.stop(); - final duration = result.elapsedMs != null - ? Duration(milliseconds: result.elapsedMs!) - : sw.elapsed; - QueryaShellStatus.instance.reportQueryResult( - duration: duration, - rowCount: result.rows.length, - columnCount: result.columns.length, - message: session.statusLine, - ); - - final cid = widget.connectionRow.id; - if (cid != null) { - unawaited( - LocalDb.instance.recordSqlQueryHistory( - connectionId: cid, - databaseName: widget.connectionRow.databaseName, - sqlText: userSql, - maxEntries: _historyMaxEntries, - ), - ); - } - } catch (e) { - if (mounted) { - setState(() { - session.error = e.toString(); - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } - } - - Future _openSqlFile() async { - try { - final file = await openFile( - acceptedTypeGroups: const [ - XTypeGroup(label: 'SQL query', extensions: ['sql']), - ], - ); - if (file == null) return; - final text = await file.readAsString(); - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: text, - selection: material.TextSelection.collapsed(offset: text.length), - ); - session.title = file.name; - session.markSaved(newFilePath: file.path); - setState(() {}); - } else { - _addNewTab(initialSql: text, title: file.name, filePath: file.path); - } - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to open SQL file: $e', - variant: AppToastVariant.error, - ); - } - } - - Future _saveSqlFile() async { - try { - final session = _activeSession; - final existingPath = session.filePath; - if (existingPath != null && existingPath.isNotEmpty) { - await File(existingPath).writeAsString(session.controller.text); - session.markSaved(); - if (!mounted) return; - showAppToast( - context: context, - message: 'Saved ${session.title}', - variant: AppToastVariant.success, - ); - return; - } - - final suggested = session.title.endsWith('.sql') - ? session.title - : '${session.title}.sql'; - final location = await getSaveLocation( - acceptedTypeGroups: const [ - XTypeGroup(label: 'SQL', extensions: ['sql']), - ], - suggestedName: suggested, - ); - final path = location?.path; - if (path == null || path.isEmpty) return; - await File(path).writeAsString(session.controller.text); - if (!mounted) return; - setState(() { - session.title = File(path).uri.pathSegments.last; - session.markSaved(newFilePath: path); - }); - showAppToast( - context: context, - message: 'Saved to ${session.title}', - variant: AppToastVariant.success, - ); - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to save SQL file: $e', - variant: AppToastVariant.error, - ); + ws.addNewTab(initialSql: sql, title: obj.name); + unawaited(ws.execute(ws.activeSession)); } } @override material.Widget build(material.BuildContext context) { - return Actions( - actions: >{ - NewSqlIntent: CallbackAction( - onInvoke: (intent) { - _addNewTab(); - return null; - }, - ), - CloseSqlTabIntent: CallbackAction( - onInvoke: (intent) { - if (_sessions.length > 1) { - unawaited(_closeTab(_activeSessionIndex)); - } - return null; - }, - ), - NextSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _nextTab(); - return null; - }, - ), - PrevSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _prevTab(); - return null; - }, - ), - OpenSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_openSqlFile()); - return null; - }, - ), - SaveSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_saveSqlFile()); - return null; - }, - ), - }, - child: material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): _prevTab, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - }, - child: material.Focus( - autofocus: true, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - SqlQueryTabBar( - sessions: _sessions, - selectedIndex: _activeSessionIndex, - onSelect: (index) => setState(() => _activeSessionIndex = index), - onAdd: _addNewTab, - onClose: _sessions.length > 1 - ? (index) => unawaited(_closeTab(index)) - : null, - ), - material.Expanded( - child: material.IndexedStack( - index: _activeSessionIndex, - children: [ - for (final session in _sessions) - _buildSessionPane(context, session), - ], - ), - ), - ], - ), - ), - ), - ); - } - - material.Widget _buildSessionPane( - material.BuildContext context, - SqlQueryTabSession session, - ) { - final theme = Theme.of(context); - return VerticalSplitPane( - fraction: session.topFraction, - maxFraction: 0.85, - top: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, + final accent = context.workbench.accent; + return GenericSqlWorkspace( + key: _workspaceKey, + connectionRow: widget.connectionRow, + delegate: _delegate, + dialect: SqlDialect.mysql, + sessionPrefix: 'ext', + initialSql: widget.initialSql, + initialTabTitle: widget.selectedObject?.name, + supportsAutocommit: false, + supportsStmtTimeout: false, + effectiveDatabaseName: () => widget.connectionRow.databaseName ?? '', + headerBadge: material.Row( + mainAxisSize: material.MainAxisSize.min, children: [ - _ExtensionSqlToolbar( - connectionName: widget.connectionRow.name, - onExecute: session.running ? null : () => unawaited(_execute(session)), - running: session.running, - isRestarting: _restartingDriver, - onRestartDriver: - session.running ? null : () => unawaited(_restartDriver()), - onOpenSqlFile: () => unawaited(_openSqlFile()), - onSaveSqlFile: () => unawaited(_saveSqlFile()), - onOpenHistory: widget.connectionRow.id != null && !session.running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: widget.connectionRow.databaseName, - sqlController: session.controller, - onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), - ); - } - : null, - ), - const Divider(height: 1), - material.Expanded( - child: QueryEditorTab( - controller: session.controller, - fontSize: _editorFontSize, - ), - ), + material.Text( + 'Query · ${widget.connectionRow.name}', + ).semiBold().small(), ], ), - bottom: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - material.Expanded( - child: ResultsTab( - columns: session.columns, - rows: session.rows, - errorMessage: session.error, - isLoading: session.running, - statusLine: session.statusLine, - errorAction: _isDriverError(session.error) - ? ExtensionDriverRecoveryBanner( - onRestart: () => unawaited(_restartDriver()), - isRestarting: _restartingDriver, - ) - : null, - ), - ), - ], - ), - ); - } -} - -class _ExtensionSqlToolbar extends material.StatelessWidget { - const _ExtensionSqlToolbar({ - required this.connectionName, - required this.onExecute, - required this.running, - required this.onOpenSqlFile, - required this.onSaveSqlFile, - this.onOpenHistory, - this.onRestartDriver, - this.isRestarting = false, - }); - - final String connectionName; - final VoidCallback? onExecute; - final bool running; - final VoidCallback onOpenSqlFile; - final VoidCallback onSaveSqlFile; - final VoidCallback? onOpenHistory; - final VoidCallback? onRestartDriver; - final bool isRestarting; - - @override - material.Widget build(material.BuildContext context) { - final accent = context.workbench.accent; - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Row( - children: [ - material.Flexible( - child: Text('Query · $connectionName').semiBold().small(), - ), - const Spacer(), - if (onRestartDriver != null) ...[ + extraToolbarTrailing: (ctx, session) { + return material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ IconButton.ghost( - onPressed: running || isRestarting ? null : onRestartDriver, - icon: isRestarting + onPressed: session.running || _restartingDriver + ? null + : () => unawaited(_restartDriver()), + icon: _restartingDriver ? QueryaSpinner( size: QueryaSpinnerSize.sm, color: accent, @@ -718,52 +250,37 @@ class _ExtensionSqlToolbar extends material.StatelessWidget { ), ), const Gap(4), - ], - IconButton.ghost( - onPressed: running ? null : onOpenSqlFile, - icon: material.Icon( - material.Icons.folder_open_rounded, - size: 18, - color: accent, - ), - ), - const Gap(4), - IconButton.ghost( - onPressed: onSaveSqlFile, - icon: material.Icon( - material.Icons.save_outlined, - size: 18, - color: accent, + IconButton.ghost( + onPressed: session.running + ? null + : () => unawaited(_workspaceKey.currentState?.openSqlFile()), + icon: material.Icon( + material.Icons.folder_open_rounded, + size: 18, + color: accent, + ), ), - ), - const Gap(8), - OutlineButton( - size: ButtonSize.small, - onPressed: onOpenHistory, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, + const Gap(4), + IconButton.ghost( + onPressed: () => + unawaited(_workspaceKey.currentState?.saveSqlFile()), + icon: material.Icon( + material.Icons.save_outlined, + size: 18, + color: accent, + ), ), - child: const Text('History'), - ), - const Gap(8), - OutlineButton( - onPressed: onExecute, - leading: running - ? QueryaSpinner( - size: QueryaSpinnerSize.sm, - color: accent, - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), - ), - ], - ), + ], + ); + }, + errorActionBuilder: (ctx, session) { + return _isDriverError(session.error) + ? ExtensionDriverRecoveryBanner( + onRestart: () => unawaited(_restartDriver()), + isRestarting: _restartingDriver, + ) + : null; + }, ); } } diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index eadea64d..87056210 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,12 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; -import 'package:file_selector/file_selector.dart'; -import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; -import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; -import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; @@ -14,599 +8,136 @@ import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/stream_take_drain.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/ui/querya_shell_status.dart'; -import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; -import 'package:querya_desktop/features/settings/preferences_dialog.dart'; -import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; -import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Ad-hoc SQL editor + results for MySQL / MariaDB. -class MysqlSqlWorkspace extends material.StatefulWidget { - const MysqlSqlWorkspace({ - super.key, +/// Execution delegate for MySQL / MariaDB connections. +class MysqlSqlExecutionDelegate extends SqlExecutionDelegate { + MysqlSqlExecutionDelegate({ required this.connectionRow, - this.transactionOpenNotifier, - this.isReadOnly = false, + required this.isReadOnly, }); final ConnectionRow connectionRow; final bool isReadOnly; - /// Updated when transaction state changes (for tab-switch warnings). - final material.ValueNotifier? transactionOpenNotifier; - - @override - material.State createState() => _MysqlSqlWorkspaceState(); -} - -class _MysqlSqlWorkspaceState extends material.State { - final List _sessions = []; - int _activeSessionIndex = 0; - int _nextSessionId = 1; - - SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; - - /// Built session panes, keyed by [SqlQueryTabSession.id]. - /// - /// Switching tabs only changes [_activeSessionIndex]; reusing the same - /// widget instance for untouched sessions lets Flutter's element diffing - /// (`identical(oldWidget, newWidget)`) skip rebuilding their subtrees, so a - /// switch only rebuilds the tab strip and swaps the visible `IndexedStack` - /// child instead of rebuilding every tab's editor and results grid. - final Map _paneCache = {}; - - void _invalidatePane(SqlQueryTabSession session) => - _paneCache.remove(session.id); - - void _invalidateAllPanes() => _paneCache.clear(); - MysqlLease? _lease; - bool? _txOpen; - - int? _queryTimeoutSeconds; - - int _resultMaxRows = kDefaultSqlResultMaxRows; - int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; - double _editorFontSize = kDefaultSqlEditorFontSize; - - late final VoidCallback _appSettingsListener; - - @override - void initState() { - super.initState(); - _sessions.add( - SqlQueryTabSession( - id: 'mysql_tab_1', - title: 'Query 1', - ), - ); - _appSettingsListener = () { - unawaited(_loadWorkspaceSettings()); - }; - SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); - material.WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_loadWorkspaceSettings()); - _registerSqlEditorCommands(); - }); - } - - void _registerSqlEditorCommands() { - if (!mounted) return; - SqlEditorCommandBridge.instance.register( - connectionId: widget.connectionRow.id, - onNew: _addNewTab, - onOpen: () => unawaited(_openSqlFile()), - onSave: () => unawaited(_saveSqlFile()), - onExecute: () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - onCloseTab: () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - onNextTab: _nextTab, - onPrevTab: _prevTab, - onFormat: () { - _activeSession.formatSql(); - _invalidatePane(_activeSession); - setState(() {}); - }, - onClear: () { - _activeSession.clearSql(); - _invalidatePane(_activeSession); - setState(() {}); - }, - onOpenWithContent: (sql, filePath, title) { - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: sql, - selection: material.TextSelection.collapsed(offset: sql.length), - ); - session.title = title; - session.filePath = filePath; - _invalidatePane(session); - setState(() {}); - } else { - _addNewTab(initialSql: sql, title: title, filePath: filePath); - } - }, - ); - } - - void _addNewTab({String initialSql = '', String? title, String? filePath}) { - setState(() { - _nextSessionId++; - final session = SqlQueryTabSession( - id: 'mysql_tab_${DateTime.now().millisecondsSinceEpoch}_$_nextSessionId', - title: title ?? 'Query $_nextSessionId', - initialSql: initialSql, - filePath: filePath, - initialFraction: - _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65, - ); - _sessions.add(session); - _activeSessionIndex = _sessions.length - 1; - }); - } - - Future _closeTab(int index) async { - if (index < 0 || index >= _sessions.length) return; - if (_sessions.length <= 1) return; - final session = _sessions[index]; - if (session.isDirty) { - final hasDirtyStaging = - session.stagingBuffer != null && session.stagingBuffer!.isDirty; - final hasUnsavedText = session.isModified || - (session.filePath == null && - session.controller.text.trim().isNotEmpty); - final String message; - if (hasDirtyStaging && hasUnsavedText) { - message = - 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; - } else if (hasDirtyStaging) { - message = - 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; - } else { - message = - 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; - } - final confirmed = await showUnsavedTabChangesDialog( - context: context, - tabTitle: session.title, - message: message, - ); - if (confirmed != true) return; - } - if (!mounted) return; - setState(() { - _sessions.removeAt(index); - session.dispose(); - _paneCache.remove(session.id); - if (_activeSessionIndex >= _sessions.length) { - _activeSessionIndex = _sessions.length - 1; - } - }); - } - - void _nextTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = (_activeSessionIndex + 1) % _sessions.length; - }); - } - - void _prevTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = - (_activeSessionIndex - 1 + _sessions.length) % _sessions.length; - }); - } - - @override - void didUpdateWidget(covariant MysqlSqlWorkspace oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.isReadOnly != widget.isReadOnly) { - _lease?.release(); - _lease = null; - // Cached panes closed over the old widget.isReadOnly in their - // onApplyChanges callback (#1007): rebuild every tab so Save reflects - // the lock immediately instead of only on that tab's next query run. - _invalidateAllPanes(); - } - } - - Future _loadWorkspaceSettings() async { - final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); - final rows = await AppSettings.instance.getSqlResultMaxRows(); - final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); - final font = await AppSettings.instance.getSqlEditorFontSize(); - if (!mounted) return; - _invalidateAllPanes(); - setState(() { - _queryTimeoutSeconds = t; - _resultMaxRows = rows; - _historyMaxEntries = hist; - _editorFontSize = font; - }); - } - void _onStmtTimeoutChanged(int? v) { - _invalidateAllPanes(); - setState(() => _queryTimeoutSeconds = v); - unawaited(AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v)); - } + MysqlLease? get lease => _lease; - String _poolDatabaseKey() => widget.connectionRow.databaseName ?? ''; + String get poolDatabaseKey => connectionRow.databaseName ?? ''; - Future _ensureLease() async { + Future ensureLease() async { if (_lease != null && _lease!.connection.isConnected) return; _lease?.release(); _lease = null; final lease = await MysqlService.instance.acquire( - widget.connectionRow, - database: _poolDatabaseKey(), - mode: widget.isReadOnly - ? MysqlSessionMode.readOnly - : MysqlSessionMode.readWrite, + connectionRow, + database: poolDatabaseKey, + mode: isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, ); - if (!mounted) { - lease.release(); - return; - } _lease = lease; } - void _notifyTransactionOpen() { - widget.transactionOpenNotifier?.value = _txOpen; + void dropLease() { + _lease?.release(); + _lease = null; } - Future _refreshTxStatus() async { + @override + bool get supportsTransactions => true; + + @override + Future checkTransactionOpen() async { final conn = _lease?.connection; if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidateAllPanes(); - setState(() => _txOpen = null); - } - _notifyTransactionOpen(); - return; - } - final v = await conn.inOpenTransaction(); - if (mounted) { - _invalidateAllPanes(); - setState(() => _txOpen = v); + return null; } - _notifyTransactionOpen(); + return conn.inOpenTransaction(); } - Duration? _statementTimeout() => _queryTimeoutSeconds == null - ? null - : Duration(seconds: _queryTimeoutSeconds!); - @override - void dispose() { - SqlEditorCommandBridge.instance - .unregister(connectionId: widget.connectionRow.id); - SqlWorkspaceSettingsRevision.listenable - .removeListener(_appSettingsListener); - final anyRunning = _sessions.any((s) => s.running); - if (anyRunning) { - MysqlService.instance.interrupt( - widget.connectionRow, - database: _poolDatabaseKey(), - mode: widget.isReadOnly - ? MysqlSessionMode.readOnly - : MysqlSessionMode.readWrite, - ); - } - _lease?.release(); - for (final s in _sessions) { - s.dispose(); + Future runTransactionCommand(String command, {Duration? timeout}) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } - _paneCache.clear(); - super.dispose(); + await conn.executeWithTimeout(command, timeout: timeout); } - Future _execute([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - final selection = session.controller.selection; - String userSql; - if (selection.isValid && !selection.isCollapsed) { - userSql = selection.textInside(session.controller.text).trim(); - } else { - userSql = session.controller.text.trim(); + @override + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } - if (userSql.isEmpty) return; - final safeToProceed = await confirmDiscardTableEditsIfDirty( - context: context, - buffer: session.stagingBuffer, - tableTitle: session.title, - ); - if (!safeToProceed) return; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { - session.stagingBuffer?.dispose(); - session.stagingBuffer = null; - } + final cap = limit ?? kDefaultSqlResultMaxRows; + final effectiveSql = injectSqlLimit(sql, cap); + final rs = await conn.executeWithTimeout(effectiveSql, timeout: timeout, iterable: true); - final confirmDestructive = - await AppSettings.instance.getConfirmDestructiveOperations(); - if (confirmDestructive) { - final inspection = DestructiveSqlDetector.inspect(userSql); - if (inspection.isDestructive) { - if (!mounted) return; - final confirmed = await showDestructiveQueryDialog( - context: context, - result: inspection, - sql: userSql, - connectionName: widget.connectionRow.name, - ); - if (confirmed != true) return; - } + final cols = []; + for (final c in rs.cols) { + cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - _invalidatePane(session); - setState(() { - session.running = true; - session.error = null; - session.columns = []; - session.rows = []; - session.affectedRows = null; - session.statusLine = null; - session.resultGridPrimaryKeys = const []; - session.resultGridColumnDataTypes = null; - session.resultGridColumnMeta = null; - }); - QueryaShellStatus.instance.beginBusy(message: 'Running query…'); - final sw = Stopwatch()..start(); - - try { - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Could not connect to MySQL.'; - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); + final taken = await takeThenDrain( + rs.rowsStream, + cap, + onProgress: (n) async { + if (n % kResultStringConvertYieldEvery == 0) { + await Future.delayed(Duration.zero); } - return; - } - - final to = _statementTimeout(); - final cap = _resultMaxRows; - final sql = injectSqlLimit(userSql, cap); - final rs = - await conn.executeWithTimeout(sql, timeout: to, iterable: true); - - if (!mounted) return; - - final cols = []; - for (final c in rs.cols) { - cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); - } - - // Convert while streaming — no Object? matrix + isolate double-copy (#421). - // Drain leftover rows so COM_QUERY reaches EOF (#808); do not cancel. - final taken = await takeThenDrain( - rs.rowsStream, - cap, - onProgress: (n) async { - if (n % kResultStringConvertYieldEvery == 0) { - await Future.delayed(Duration.zero); - } - }, - ); - final colList = rs.cols.toList(); - final outRows = [ - for (final row in taken.items) - List.generate( - row.numOfColumns, - (i) { - final col = i < colList.length ? colList[i] : null; - return mysqlResultCellToDisplayString( - row.colAt(i), - column: col, - ); - }, - ), - ]; - final truncated = - taken.truncated || (sql != userSql && outRows.length >= cap); - final n = outRows.length; - - int? affected; - if (cols.isEmpty && outRows.isEmpty) { - affected = _affectedInt(rs.affectedRows); - } - - final target = SqlTableTargetExtractor.extract(userSql); - var gridSchema = SqlResultGridSchema.none; - final schemaName = target?.schema ?? _poolDatabaseKey(); - if (target != null && cols.isNotEmpty && schemaName.isNotEmpty) { - gridSchema = SqlResultGridSchema.fromLoad( - await loadTableViewSchema( - () => conn.getTableSchema( - database: schemaName, - table: target.tableName, - ), - ), - ); - } - final pks = gridSchema.primaryKeys; - final editHint = gridSchema.editHint(cols); - final canSave = sqlResultGridSaveEnabled( - sql: userSql, - resultColumns: cols, - primaryKeys: pks, - ); - - _invalidatePane(session); - setState(() { - session.columns = cols; - session.rows = outRows; - session.affectedRows = affected; - session.lastExecutedSql = userSql; - session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = gridSchema.columnDataTypes; - session.resultGridColumnMeta = gridSchema.columnMeta; - session.stagingBuffer?.dispose(); - session.stagingBuffer = canSave - ? DataGridStagingBuffer( - columns: cols, - rows: outRows, - primaryKeys: pks, - ) - : null; - if (cols.isEmpty && outRows.isEmpty) { - session.statusLine = affected != null - ? 'OK. Rows affected: $affected.' - : 'Command completed.'; - } else { - session.statusLine = withEditHint( - truncated - ? 'Showing first $cap row(s) (result capped).' - : '$n row(s).', - editHint, - ); - } - session.running = false; - }); - sw.stop(); - QueryaShellStatus.instance.reportQueryResult( - duration: sw.elapsed, - rowCount: outRows.length, - columnCount: cols.length, - message: session.statusLine, - ); - final cid = widget.connectionRow.id; - if (cid != null) { - unawaited( - LocalDb.instance.recordSqlQueryHistory( - connectionId: cid, - databaseName: widget.connectionRow.databaseName, - sqlText: userSql, - maxEntries: _historyMaxEntries, - ), - ); - } - } on TimeoutException catch (e) { - unawaited(_lease?.connection.forceClose()); - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } finally { - await _refreshTxStatus(); + }, + ); + final colList = rs.cols.toList(); + final outRows = [ + for (final row in taken.items) + List.generate( + row.numOfColumns, + (i) { + final col = i < colList.length ? colList[i] : null; + return mysqlResultCellToDisplayString( + row.colAt(i), + column: col, + ); + }, + ), + ]; + final truncated = + taken.truncated || (effectiveSql != sql && outRows.length >= cap); + final n = outRows.length; + + int? affected; + if (cols.isEmpty && outRows.isEmpty) { + affected = _affectedInt(rs.affectedRows); } - } - Future _applyStagedChanges([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - if (widget.isReadOnly || - session.stagingBuffer == null || - !session.stagingBuffer!.isDirty || - session.savingChanges) { - return; - } - final target = session.lastExecutedSql != null - ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) - : null; - if (target == null || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - )) { - return; + String? statusMsg; + if (cols.isEmpty && outRows.isEmpty) { + statusMsg = affected != null + ? 'OK. Rows affected: $affected.' + : 'Command completed.'; + } else { + statusMsg = truncated + ? 'Showing first $cap row(s) (result capped).' + : '$n row(s).'; } - final schemaName = target.schema ?? - (widget.connectionRow.databaseName?.trim().isNotEmpty == true - ? widget.connectionRow.databaseName!.trim() - : null); - - _invalidatePane(session); - setState(() => session.savingChanges = true); - try { - final plan = session.stagingBuffer!.generateMutationPlan( - dialect: SqlDialect.mysql, - tableName: target.tableName, - schema: schemaName, - primaryKeys: session.resultGridPrimaryKeys, - columnDataTypes: session.resultGridColumnDataTypes, - columnMeta: session.resultGridColumnMeta, - ); - if (plan.isEmpty) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - return; - } - - final confirmed = await showDmlPreviewDialog( - context: context, - plan: plan, - ); - if (confirmed != true) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - return; - } - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to MySQL.'); - } - - await conn.runInTransaction(() async { - for (final stmt in plan.statements) { - final rs = await conn.execute(stmt.sql); - expectDmlMatchedRows(rs.affectedRows.toInt()); - } - }); - await _refreshTxStatus(); - - if (!mounted) return; - final newRows = session.stagingBuffer!.committedRows; - session.stagingBuffer?.dispose(); - _invalidatePane(session); - setState(() { - session.rows = newRows; - session.stagingBuffer = DataGridStagingBuffer( - columns: session.columns, - rows: session.rows, - primaryKeys: session.resultGridPrimaryKeys, - ); - session.savingChanges = false; - }); - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - await showTableViewSaveFailedDialog(context: context, error: e); - } - } + return SqlExecutionResult( + columns: cols, + rows: outRows, + affectedRows: affected, + statusMessage: statusMsg, + isTruncated: truncated, + ); } static int? _affectedInt(BigInt v) { @@ -614,478 +145,151 @@ class _MysqlSqlWorkspaceState extends material.State { return v.toInt(); } - Future _openSqlFile() async { - try { - final file = await openFile( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'SQL query', - extensions: ['sql'], - ), - ], - ); - if (file == null) return; - final text = await file.readAsString(); - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: text, - selection: material.TextSelection.collapsed(offset: text.length), - ); - session.title = file.name; - session.markSaved(newFilePath: file.path); - _invalidatePane(session); - setState(() {}); - } else { - _addNewTab(initialSql: text, title: file.name, filePath: file.path); - } - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to open SQL file: $e', - variant: AppToastVariant.error, - ); + @override + Future explainQuery(String sql) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } + final rs = await conn.executeWithTimeout('EXPLAIN $sql'); + final sb = StringBuffer(); + for (final row in rs.rows) { + sb.writeln(row.assoc().entries.map((e) => '${e.key}: ${e.value}').join(', ')); + } + return sb.toString(); } - Future _saveSqlFile() async { - try { - final session = _activeSession; - final existingPath = session.filePath; - if (existingPath != null && existingPath.isNotEmpty) { - await File(existingPath).writeAsString(session.controller.text); - session.markSaved(); - if (!mounted) return; - showAppToast( - context: context, - message: 'Saved ${session.title}', - variant: AppToastVariant.success, - ); - return; - } + @override + Future cancelQuery() async { + MysqlService.instance.interrupt( + connectionRow, + database: poolDatabaseKey, + mode: isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, + ); + await _lease?.connection.forceClose(); + dropLease(); + } - final suggested = session.title.endsWith('.sql') - ? session.title - : '${session.title}.sql'; - final location = await getSaveLocation( - acceptedTypeGroups: const [ - XTypeGroup(label: 'SQL', extensions: ['sql']), - ], - suggestedName: suggested, - ); - final path = location?.path; - if (path == null || path.isEmpty) return; - await File(path).writeAsString(session.controller.text); - if (!mounted) return; - _invalidatePane(session); - setState(() { - session.title = File(path).uri.pathSegments.last; - session.markSaved(newFilePath: path); - }); - showAppToast( - context: context, - message: 'Saved to ${session.title}', - variant: AppToastVariant.success, - ); - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to save SQL file: $e', - variant: AppToastVariant.error, + @override + Future resolveTableSchema( + String userSql, + List columns, + ) async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + return SqlResultGridSchema.none; + } + final target = SqlTableTargetExtractor.extract(userSql); + final schemaName = target?.schema ?? poolDatabaseKey; + if (target != null && columns.isNotEmpty && schemaName.isNotEmpty) { + return SqlResultGridSchema.fromLoad( + await loadTableViewSchema( + () => conn.getTableSchema( + database: schemaName, + table: target.tableName, + ), + ), ); } + return SqlResultGridSchema.none; } - Future _runTxCommand(String cmd) async { - final session = _activeSession; - _invalidatePane(session); - setState(() { - session.running = true; - session.error = null; - }); - try { - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Could not connect to MySQL.'; - session.running = false; - }); - } - return; - } - final to = _statementTimeout(); - await conn.executeWithTimeout(cmd, timeout: to); - if (!mounted) return; - _invalidatePane(session); - setState(() { - session.columns = []; - session.rows = []; - session.affectedRows = null; - session.statusLine = 'OK: $cmd'; - session.running = false; - }); - } on TimeoutException catch (e) { - unawaited(_lease?.connection.forceClose()); - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - } - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - } - } finally { - await _refreshTxStatus(); + @override + Future applyStagedMutations({ + required TableMutationPlan plan, + Duration? timeout, + }) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } + + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); + } + }); } @override - material.Widget build(material.BuildContext context) { - return Actions( - actions: >{ - NewSqlIntent: CallbackAction( - onInvoke: (intent) { - _addNewTab(); - return null; - }, - ), - CloseSqlTabIntent: CallbackAction( - onInvoke: (intent) { - if (_sessions.length > 1) { - unawaited(_closeTab(_activeSessionIndex)); - } - return null; - }, - ), - NextSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _nextTab(); - return null; - }, - ), - PrevSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _prevTab(); - return null; - }, - ), - OpenSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_openSqlFile()); - return null; - }, - ), - SaveSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_saveSqlFile()); - return null; - }, - ), - }, - child: material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, - control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): - _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, - control: true): () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): - () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): - _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, - control: true, shift: true): _prevTab, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - }, - child: material.Focus( - autofocus: true, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - SqlQueryTabBar( - sessions: _sessions, - selectedIndex: _activeSessionIndex, - onSelect: (index) => - setState(() => _activeSessionIndex = index), - onAdd: _addNewTab, - onClose: _sessions.length > 1 - ? (index) => unawaited(_closeTab(index)) - : null, - ), - material.Expanded( - child: material.IndexedStack( - index: _activeSessionIndex, - children: [ - for (final session in _sessions) - _paneCache.putIfAbsent( - session.id, - () => _buildSessionPane(context, session), - ), - ], - ), - ), - ], - ), - ), - ), - ); + void dispose() { + dropLease(); } +} - material.Widget _buildSessionPane( - material.BuildContext context, - SqlQueryTabSession session, - ) { - final theme = Theme.of(context); - return VerticalSplitPane( - key: material.ValueKey(session.id), - fraction: session.topFraction, - maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _MysqlSqlToolbar( - onExecute: session.running ? null : () => _execute(session), - running: session.running, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !session.running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: widget.connectionRow.databaseName, - sqlController: session.controller, - onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), - ); - } - : null, - txOpen: _txOpen, - onBegin: session.running - ? null - : () => _runTxCommand('START TRANSACTION'), - onCommit: session.running ? null : () => _runTxCommand('COMMIT'), - onRollback: - session.running ? null : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: session.controller, - fontSize: _editorFontSize, - ), - ), - ], - ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: session.columns, - rows: session.rows, - errorMessage: session.error, - isLoading: session.running, - affectedRows: session.affectedRows, - statusLine: session.statusLine, - stagingBuffer: session.stagingBuffer, - columnDataTypes: session.resultGridColumnDataTypes, - onApplyChanges: widget.isReadOnly || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - ) - ? null - : () => _applyStagedChanges(session), - isSaving: session.savingChanges, - ), - ), - ], - ), +/// Ad-hoc SQL editor + results for MySQL / MariaDB. +class MysqlSqlWorkspace extends material.StatefulWidget { + const MysqlSqlWorkspace({ + super.key, + required this.connectionRow, + this.transactionOpenNotifier, + this.isReadOnly = false, + }); + + final ConnectionRow connectionRow; + final bool isReadOnly; + + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; + + @override + material.State createState() => _MysqlSqlWorkspaceState(); +} + +class _MysqlSqlWorkspaceState extends material.State { + late MysqlSqlExecutionDelegate _delegate; + final material.GlobalKey _workspaceKey = + material.GlobalKey(); + + @override + void initState() { + super.initState(); + _delegate = MysqlSqlExecutionDelegate( + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, ); } -} -class _MysqlSqlToolbar extends material.StatelessWidget { - const _MysqlSqlToolbar({ - required this.onExecute, - required this.running, - required this.queryTimeoutSeconds, - required this.onQueryTimeoutChanged, - required this.onOpenPreferences, - this.onOpenHistory, - required this.txOpen, - required this.onBegin, - required this.onCommit, - required this.onRollback, - }); + @override + void didUpdateWidget(covariant MysqlSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly || + oldWidget.connectionRow.id != widget.connectionRow.id) { + _delegate.dispose(); + _delegate = MysqlSqlExecutionDelegate( + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, + ); + _workspaceKey.currentState?.invalidateAllPanes(); + } + } - final Future Function()? onExecute; - final bool running; - final int? queryTimeoutSeconds; - final void Function(int?) onQueryTimeoutChanged; - final VoidCallback onOpenPreferences; - final VoidCallback? onOpenHistory; - final bool? txOpen; - final VoidCallback? onBegin; - final VoidCallback? onCommit; - final VoidCallback? onRollback; + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } @override material.Widget build(material.BuildContext context) { - final accent = context.workbench.accent; - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - const Text('Query').semiBold().small(), - Text(mysqlSqlToolbarTxLabel(txOpen)).muted().small(), - OutlineButton( - size: ButtonSize.small, - onPressed: onOpenHistory, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, - ), - child: const Text('History'), - ), - OutlineButton( - onPressed: onExecute, - leading: running - ? QueryaSpinner( - size: QueryaSpinnerSize.sm, - color: accent, - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), - ), - ], - ), - const Gap(8), - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Stmt timeout').small(), - const Gap(6), - SqlStatementTimeoutDropdown( - value: queryTimeoutSeconds, - onChanged: onQueryTimeoutChanged, - enabled: !running, - ), - const Gap(4), - IconButton.ghost( - onPressed: running ? null : onOpenPreferences, - icon: material.Icon( - material.Icons.settings_rounded, - size: 20, - color: accent, - ), - ), - ], - ), - OutlineButton( - onPressed: onBegin, - child: const Text('Begin'), - ), - OutlineButton( - onPressed: onCommit, - child: const Text('Commit'), - ), - OutlineButton( - onPressed: onRollback, - child: const Text('Rollback'), - ), - ], - ), - ], - ), + return GenericSqlWorkspace( + key: _workspaceKey, + connectionRow: widget.connectionRow, + delegate: _delegate, + dialect: SqlDialect.mysql, + sessionPrefix: 'mysql', + transactionOpenNotifier: widget.transactionOpenNotifier, + isReadOnly: widget.isReadOnly, + supportsAutocommit: false, + supportsStmtTimeout: true, + getStoredTimeoutSeconds: () => + AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), + setStoredTimeoutSeconds: (v) => + AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v), + effectiveDatabaseName: () => widget.connectionRow.databaseName ?? '', ); } } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index cd8e1326..ccb25ce6 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,29 +1,16 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; -import 'package:file_selector/file_selector.dart'; -import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; -import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; -import 'package:postgres/postgres.dart' as pg; -import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; -import 'package:querya_desktop/features/settings/preferences_dialog.dart'; -import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; -import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Database used for this SQL workspace session (matches [PostgresService.acquire]). String _pgSqlSessionDatabase(ConnectionRow row) { @@ -32,6 +19,208 @@ String _pgSqlSessionDatabase(ConnectionRow row) { return d; } +/// Execution delegate for PostgreSQL connections. +class PostgresSqlExecutionDelegate extends SqlExecutionDelegate { + PostgresSqlExecutionDelegate({ + required this.connectionRow, + required this.isReadOnly, + required this.effectiveDatabaseProvider, + required this.autocommitProvider, + }); + + final ConnectionRow connectionRow; + final bool isReadOnly; + final String Function() effectiveDatabaseProvider; + final bool Function() autocommitProvider; + + PgLease? _lease; + String? _interruptDatabase; + + PgLease? get lease => _lease; + + Future ensureLease() async { + if (_lease != null && _lease!.connection.isConnected) return; + dropLease(); + final db = effectiveDatabaseProvider(); + final lease = await PostgresService.instance.acquire( + connectionRow, + database: db, + mode: isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, + ); + _lease = lease; + _interruptDatabase = db; + } + + void dropLease() { + _lease?.release(); + _lease = null; + _interruptDatabase = null; + } + + @override + bool get supportsTransactions => true; + + @override + Future checkTransactionOpen() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + return null; + } + return conn.inOpenTransaction(); + } + + @override + Future runTransactionCommand(String command, {Duration? timeout}) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + await conn.execute(command, timeout: timeout); + } + + @override + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + + final cap = limit ?? kDefaultSqlResultMaxRows; + final effectiveSql = injectSqlLimit(sql, cap); + + if (!autocommitProvider()) { + final inTx = await conn.inOpenTransaction() ?? false; + if (!inTx && !shouldSkipImplicitBegin(effectiveSql)) { + await conn.execute('BEGIN', timeout: timeout); + } + } + + final result = await conn.execute(effectiveSql, timeout: timeout); + + final schema = result.schema; + final cols = []; + for (var i = 0; i < schema.columns.length; i++) { + final c = schema.columns[i]; + cols.add( + c.columnName?.isNotEmpty == true ? c.columnName! : '[$i]', + ); + } + + final rawRows = >[]; + var n = 0; + for (final row in result) { + if (n >= cap) break; + rawRows.add(row.toList()); + n++; + } + + final outRows = await convertPostgresResultRowsToStringsAdaptive( + PostgresResultConvertJob( + rowValues: rawRows, + columnTypeOids: [ + for (final c in schema.columns) c.typeOid, + ], + ), + ); + + final isTruncated = result.length >= cap; + String? statusMsg; + if (cols.isEmpty && outRows.isEmpty) { + statusMsg = 'Command completed. Rows affected: ${result.affectedRows}.'; + } else { + statusMsg = isTruncated + ? 'Showing first $cap row(s) (result capped).' + : '${result.length} row(s).'; + } + + return SqlExecutionResult( + columns: cols, + rows: outRows, + affectedRows: result.affectedRows, + statusMessage: statusMsg, + isTruncated: isTruncated, + ); + } + + @override + Future explainQuery(String sql) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + final res = await conn.execute('EXPLAIN $sql'); + return res.map((r) => r.first.toString()).join('\n'); + } + + @override + Future cancelQuery() async { + PostgresService.instance.interrupt( + connectionRow, + database: _interruptDatabase ?? effectiveDatabaseProvider(), + mode: isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, + ); + await _lease?.connection.forceClose(); + dropLease(); + } + + @override + Future resolveTableSchema( + String userSql, + List columns, + ) async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + return SqlResultGridSchema.none; + } + final target = SqlTableTargetExtractor.extract(userSql); + if (target != null && columns.isNotEmpty) { + return SqlResultGridSchema.fromLoad( + await loadTableViewSchema( + () => conn.getTableSchema( + schema: target.schema ?? 'public', + table: target.tableName, + ), + ), + ); + } + return SqlResultGridSchema.none; + } + + @override + Future applyStagedMutations({ + required TableMutationPlan plan, + Duration? timeout, + }) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + + await runPostgresStatementsInTransaction( + (sql) async { + final result = await conn.execute(sql, timeout: timeout); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } + }, + plan.statements.map((s) => s.sql), + ); + } + + @override + void dispose() { + dropLease(); + } +} + /// Ad-hoc SQL editor + results for a PostgreSQL connection (pgAdmin-style). class PostgresSqlWorkspace extends material.StatefulWidget { const PostgresSqlWorkspace({ @@ -66,179 +255,33 @@ class PostgresSqlWorkspace extends material.StatefulWidget { } class _PostgresSqlWorkspaceState extends material.State { - final List _sessions = []; - int _activeSessionIndex = 0; - int _nextSessionId = 1; - - SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; - - /// Built session panes, keyed by [SqlQueryTabSession.id]. - /// - /// Switching tabs only changes [_activeSessionIndex]; reusing the same - /// widget instance for untouched sessions lets Flutter's element diffing - /// (`identical(oldWidget, newWidget)`) skip rebuilding their subtrees, so a - /// switch only rebuilds the tab strip and swaps the visible `IndexedStack` - /// child instead of rebuilding every tab's editor and results grid. - final Map _paneCache = {}; - - void _invalidatePane(SqlQueryTabSession session) => - _paneCache.remove(session.id); - - void _invalidateAllPanes() => _paneCache.clear(); - - PgLease? _lease; - - /// Database used for the current lease (for [PostgresService.interrupt]). - String? _interruptDatabase; + late PostgresSqlExecutionDelegate _delegate; + final material.GlobalKey _workspaceKey = + material.GlobalKey(); int _lastAppliedSqlContextToken = -1; - - /// PostgreSQL default: each statement is its own transaction unless you use - /// `BEGIN` / `BEGIN`+implicit when autocommit is off. bool _autocommit = true; - /// `null` = use connection / URI [query_timeout] default from driver. - int? _queryTimeoutSeconds; - - int _resultMaxRows = kDefaultSqlResultMaxRows; - int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; - double _editorFontSize = kDefaultSqlEditorFontSize; - - /// `null` = unknown (older server or error). - bool? _txOpen; - - late final VoidCallback _appSettingsListener; + String _effectiveSessionDatabase() { + final ctx = widget.postgresSqlEditorContext; + if (ctx != null) { + final d = ctx.database.trim(); + if (d.isNotEmpty) return d; + } + return _pgSqlSessionDatabase(widget.connectionRow); + } @override void initState() { super.initState(); - _sessions.add( - SqlQueryTabSession( - id: 'pg_tab_1', - title: 'Query 1', - ), + _delegate = PostgresSqlExecutionDelegate( + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, + effectiveDatabaseProvider: _effectiveSessionDatabase, + autocommitProvider: () => _autocommit, ); - _appSettingsListener = () { - unawaited(_loadWorkspaceSettings()); - }; - SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { _syncPostgresSqlTreeContext(); - unawaited(_loadWorkspaceSettings()); - _registerSqlEditorCommands(); - }); - } - - void _registerSqlEditorCommands() { - if (!mounted) return; - SqlEditorCommandBridge.instance.register( - connectionId: widget.connectionRow.id, - onNew: _addNewTab, - onOpen: () => unawaited(_openSqlFile()), - onSave: () => unawaited(_saveSqlFile()), - onExecute: () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - onCloseTab: () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - onNextTab: _nextTab, - onPrevTab: _prevTab, - onFormat: () { - _activeSession.formatSql(); - _invalidatePane(_activeSession); - setState(() {}); - }, - onClear: () { - _activeSession.clearSql(); - _invalidatePane(_activeSession); - setState(() {}); - }, - onOpenWithContent: (sql, filePath, title) { - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: sql, - selection: material.TextSelection.collapsed(offset: sql.length), - ); - session.title = title; - session.filePath = filePath; - _invalidatePane(session); - setState(() {}); - } else { - _addNewTab(initialSql: sql, title: title, filePath: filePath); - } - }, - ); - } - - void _addNewTab({String initialSql = '', String? title, String? filePath}) { - setState(() { - _nextSessionId++; - final session = SqlQueryTabSession( - id: 'pg_tab_${DateTime.now().millisecondsSinceEpoch}_$_nextSessionId', - title: title ?? 'Query $_nextSessionId', - initialSql: initialSql, - filePath: filePath, - initialFraction: - _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65, - ); - _sessions.add(session); - _activeSessionIndex = _sessions.length - 1; - }); - } - - Future _closeTab(int index) async { - if (index < 0 || index >= _sessions.length) return; - if (_sessions.length <= 1) return; - final session = _sessions[index]; - if (session.isDirty) { - final hasDirtyStaging = - session.stagingBuffer != null && session.stagingBuffer!.isDirty; - final hasUnsavedText = session.isModified || - (session.filePath == null && session.controller.text.trim().isNotEmpty); - final String message; - if (hasDirtyStaging && hasUnsavedText) { - message = - 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; - } else if (hasDirtyStaging) { - message = - 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; - } else { - message = - 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; - } - final confirmed = await showUnsavedTabChangesDialog( - context: context, - tabTitle: session.title, - message: message, - ); - if (confirmed != true) return; - } - if (!mounted) return; - setState(() { - _sessions.removeAt(index); - session.dispose(); - _paneCache.remove(session.id); - if (_activeSessionIndex >= _sessions.length) { - _activeSessionIndex = _sessions.length - 1; - } - }); - } - - void _nextTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = (_activeSessionIndex + 1) % _sessions.length; - }); - } - - void _prevTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = - (_activeSessionIndex - 1 + _sessions.length) % _sessions.length; }); } @@ -248,31 +291,20 @@ class _PostgresSqlWorkspaceState extends material.State { if (oldWidget.connectionRow.id != widget.connectionRow.id) { _lastAppliedSqlContextToken = -1; } - if (oldWidget.isReadOnly != widget.isReadOnly) { - _dropLease(); - // Cached panes closed over the old widget.isReadOnly in their - // onApplyChanges callback (#1007): rebuild every tab so Save reflects - // the lock immediately instead of only on that tab's next query run. - _invalidateAllPanes(); + if (oldWidget.isReadOnly != widget.isReadOnly || + oldWidget.connectionRow.id != widget.connectionRow.id) { + _delegate.dispose(); + _delegate = PostgresSqlExecutionDelegate( + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, + effectiveDatabaseProvider: _effectiveSessionDatabase, + autocommitProvider: () => _autocommit, + ); + _workspaceKey.currentState?.invalidateAllPanes(); } _syncPostgresSqlTreeContext(); } - String _effectiveSessionDatabase() { - final ctx = widget.postgresSqlEditorContext; - if (ctx != null) { - final d = ctx.database.trim(); - if (d.isNotEmpty) return d; - } - return _pgSqlSessionDatabase(widget.connectionRow); - } - - void _dropLease() { - _lease?.release(); - _lease = null; - _interruptDatabase = null; - } - void _syncPostgresSqlTreeContext() { final ctx = widget.postgresSqlEditorContext; final tok = widget.postgresSqlEditorContextToken; @@ -282,907 +314,51 @@ class _PostgresSqlWorkspaceState extends material.State { } if (tok == _lastAppliedSqlContextToken) return; _lastAppliedSqlContextToken = tok; - _dropLease(); + _delegate.dropLease(); + final sql = postgresBrowseSelectSql(schema: ctx.schema, table: ctx.name); - if (_activeSession.controller.text.trim().isEmpty && - _activeSession.rows.isEmpty) { - _activeSession.controller.value = material.TextEditingValue( + final ws = _workspaceKey.currentState; + if (ws == null) return; + + if (ws.activeSession.controller.text.trim().isEmpty && + ws.activeSession.rows.isEmpty) { + ws.activeSession.controller.value = material.TextEditingValue( text: sql, selection: material.TextSelection.collapsed(offset: sql.length), ); - _activeSession.title = ctx.name; + ws.activeSession.title = ctx.name; + ws.invalidatePane(ws.activeSession); + ws.setState(() {}); } else { - _addNewTab(initialSql: sql, title: ctx.name); - } - } - - Future _loadWorkspaceSettings() async { - final t = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); - final rows = await AppSettings.instance.getSqlResultMaxRows(); - final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); - final font = await AppSettings.instance.getSqlEditorFontSize(); - if (!mounted) return; - _invalidateAllPanes(); - setState(() { - _queryTimeoutSeconds = t; - _resultMaxRows = rows; - _historyMaxEntries = hist; - _editorFontSize = font; - }); - } - - void _onStmtTimeoutChanged(int? v) { - _invalidateAllPanes(); - setState(() => _queryTimeoutSeconds = v); - unawaited(AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v)); - } - - void _notifyTransactionOpen() { - widget.transactionOpenNotifier?.value = _txOpen; - } - - Future _ensureLease() async { - if (_lease != null && _lease!.connection.isConnected) return; - _dropLease(); - final db = _effectiveSessionDatabase(); - final lease = await PostgresService.instance.acquire( - widget.connectionRow, - database: db, - mode: widget.isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, - ); - if (!mounted) { - lease.release(); - return; - } - _lease = lease; - _interruptDatabase = db; - } - - Duration? _statementTimeout() => _queryTimeoutSeconds == null - ? null - : Duration(seconds: _queryTimeoutSeconds!); - - Future _refreshTxStatus() async { - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidateAllPanes(); - setState(() => _txOpen = null); - } - _notifyTransactionOpen(); - return; - } - final v = await conn.inOpenTransaction(); - if (mounted) { - _invalidateAllPanes(); - setState(() => _txOpen = v); - } - _notifyTransactionOpen(); - } - - Future _runTxCommand(String cmd) async { - final session = _activeSession; - _invalidatePane(session); - setState(() { - session.running = true; - session.error = null; - }); - try { - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Could not connect to PostgreSQL.'; - session.running = false; - }); - } - return; - } - final to = _statementTimeout(); - await conn.execute(cmd, timeout: to); - if (!mounted) return; - _invalidatePane(session); - setState(() { - session.columns = []; - session.rows = []; - session.affectedRows = null; - session.statusLine = 'OK: $cmd'; - session.running = false; - }); - } on TimeoutException catch (e) { - unawaited(_lease?.connection.forceClose()); - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Query timed out: ${e.message ?? e}'; - session.running = false; - }); - } - } on pg.ServerException catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.message; - session.running = false; - }); - } - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - } - } finally { - await _refreshTxStatus(); + ws.addNewTab(initialSql: sql, title: ctx.name); } } @override void dispose() { - SqlEditorCommandBridge.instance - .unregister(connectionId: widget.connectionRow.id); - SqlWorkspaceSettingsRevision.listenable - .removeListener(_appSettingsListener); - final anyRunning = _sessions.any((s) => s.running); - if (anyRunning) { - PostgresService.instance.interrupt( - widget.connectionRow, - database: _interruptDatabase ?? _effectiveSessionDatabase(), - mode: widget.isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, - ); - } - _dropLease(); - for (final s in _sessions) { - s.dispose(); - } - _paneCache.clear(); + _delegate.dispose(); super.dispose(); } - Future _execute([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - final selection = session.controller.selection; - String userSql; - if (selection.isValid && !selection.isCollapsed) { - userSql = selection.textInside(session.controller.text).trim(); - } else { - userSql = session.controller.text.trim(); - } - if (userSql.isEmpty) return; - - final safeToProceed = await confirmDiscardTableEditsIfDirty( - context: context, - buffer: session.stagingBuffer, - tableTitle: session.title, - ); - if (!safeToProceed) return; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { - session.stagingBuffer?.dispose(); - session.stagingBuffer = null; - } - - final confirmDestructive = - await AppSettings.instance.getConfirmDestructiveOperations(); - if (confirmDestructive) { - final inspection = DestructiveSqlDetector.inspect(userSql); - if (inspection.isDestructive) { - if (!mounted) return; - final confirmed = await showDestructiveQueryDialog( - context: context, - result: inspection, - sql: userSql, - connectionName: widget.connectionRow.name, - ); - if (confirmed != true) return; - } - } - - var sql = injectSqlLimit(userSql, _resultMaxRows); - - _invalidatePane(session); - setState(() { - session.running = true; - session.error = null; - session.columns = []; - session.rows = []; - session.affectedRows = null; - session.statusLine = null; - session.resultGridPrimaryKeys = const []; - session.resultGridColumnDataTypes = null; - session.resultGridColumnMeta = null; - }); - QueryaShellStatus.instance.beginBusy(message: 'Running query…'); - final sw = Stopwatch()..start(); - - try { - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Could not connect to PostgreSQL.'; - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - return; - } - - final to = _statementTimeout(); - if (!_autocommit) { - final inTx = await conn.inOpenTransaction() ?? false; - if (!inTx && !shouldSkipImplicitBegin(sql)) { - // Separate execute: Parse cannot take `BEGIN;` + the next statement. - await conn.execute('BEGIN', timeout: to); - } - } - - final result = await conn.execute(sql, timeout: to); - - if (!mounted) return; - - final schema = result.schema; - final cols = []; - for (var i = 0; i < schema.columns.length; i++) { - final c = schema.columns[i]; - cols.add( - c.columnName?.isNotEmpty == true ? c.columnName! : '[$i]', - ); - } - - final rawRows = >[]; - var n = 0; - final cap = _resultMaxRows; - for (final row in result) { - if (n >= cap) break; - rawRows.add(row.toList()); - n++; - } - - final outRows = await convertPostgresResultRowsToStringsAdaptive( - PostgresResultConvertJob( - rowValues: rawRows, - columnTypeOids: [ - for (final c in schema.columns) c.typeOid, - ], - ), - ); - - final target = SqlTableTargetExtractor.extract(userSql); - var gridSchema = SqlResultGridSchema.none; - if (target != null && cols.isNotEmpty) { - gridSchema = SqlResultGridSchema.fromLoad( - await loadTableViewSchema( - () => conn.getTableSchema( - schema: target.schema ?? 'public', - table: target.tableName, - ), - ), - ); - } - final pks = gridSchema.primaryKeys; - final editHint = gridSchema.editHint(cols); - final canSave = sqlResultGridSaveEnabled( - sql: userSql, - resultColumns: cols, - primaryKeys: pks, - ); - - _invalidatePane(session); - setState(() { - session.columns = cols; - session.rows = outRows; - session.affectedRows = result.affectedRows; - session.lastExecutedSql = userSql; - session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = gridSchema.columnDataTypes; - session.resultGridColumnMeta = gridSchema.columnMeta; - session.stagingBuffer?.dispose(); - session.stagingBuffer = canSave - ? DataGridStagingBuffer( - columns: cols, - rows: outRows, - primaryKeys: pks, - ) - : null; - if (cols.isEmpty && outRows.isEmpty) { - session.statusLine = - 'Command completed. Rows affected: ${result.affectedRows}.'; - } else { - final truncated = result.length >= cap; - session.statusLine = withEditHint( - truncated - ? 'Showing first $cap row(s) (result capped).' - : '${result.length} row(s).', - editHint, - ); - } - session.running = false; - }); - sw.stop(); - QueryaShellStatus.instance.reportQueryResult( - duration: sw.elapsed, - rowCount: outRows.length, - columnCount: cols.length, - message: session.statusLine, - ); - final cid = widget.connectionRow.id; - if (cid != null) { - unawaited( - LocalDb.instance.recordSqlQueryHistory( - connectionId: cid, - databaseName: _effectiveSessionDatabase(), - sqlText: userSql, - maxEntries: _historyMaxEntries, - ), - ); - } - } on TimeoutException catch (e) { - unawaited(_lease?.connection.forceClose()); - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Query timed out: ${e.message ?? e}'; - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } on pg.ServerException catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.message; - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } finally { - await _refreshTxStatus(); - } - } - - Future _applyStagedChanges([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - if (widget.isReadOnly || - session.stagingBuffer == null || - !session.stagingBuffer!.isDirty || - session.savingChanges) { - return; - } - final target = session.lastExecutedSql != null - ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) - : null; - if (target == null || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - )) { - return; - } - - _invalidatePane(session); - setState(() => session.savingChanges = true); - try { - final plan = session.stagingBuffer!.generateMutationPlan( - dialect: SqlDialect.postgres, - tableName: target.tableName, - schema: target.schema ?? 'public', - primaryKeys: session.resultGridPrimaryKeys, - columnDataTypes: session.resultGridColumnDataTypes, - columnMeta: session.resultGridColumnMeta, - ); - if (plan.isEmpty) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - return; - } - - final confirmed = await showDmlPreviewDialog( - context: context, - plan: plan, - ); - if (confirmed != true) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - return; - } - - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to PostgreSQL.'); - } - - final to = _statementTimeout(); - await runPostgresStatementsInTransaction( - (sql) async { - final result = await conn.execute(sql, timeout: to); - if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { - expectDmlMatchedRows(result.affectedRows); - } - }, - plan.statements.map((s) => s.sql), - ); - - if (!mounted) return; - final newRows = session.stagingBuffer!.committedRows; - session.stagingBuffer?.dispose(); - _invalidatePane(session); - setState(() { - session.rows = newRows; - session.stagingBuffer = - DataGridStagingBuffer( - columns: session.columns, - rows: session.rows, - primaryKeys: session.resultGridPrimaryKeys, - ); - session.savingChanges = false; - }); - await _refreshTxStatus(); - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - await showTableViewSaveFailedDialog(context: context, error: e); - } - } - } - - Future _openSqlFile() async { - try { - final file = await openFile( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'SQL query', - extensions: ['sql'], - ), - ], - ); - if (file == null) return; - final text = await file.readAsString(); - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: text, - selection: material.TextSelection.collapsed(offset: text.length), - ); - session.title = file.name; - session.markSaved(newFilePath: file.path); - _invalidatePane(session); - setState(() {}); - } else { - _addNewTab(initialSql: text, title: file.name, filePath: file.path); - } - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to open SQL file: $e', - variant: AppToastVariant.error, - ); - } - } - - Future _saveSqlFile() async { - try { - final session = _activeSession; - final existingPath = session.filePath; - if (existingPath != null && existingPath.isNotEmpty) { - await File(existingPath).writeAsString(session.controller.text); - session.markSaved(); - if (!mounted) return; - showAppToast( - context: context, - message: 'Saved ${session.title}', - variant: AppToastVariant.success, - ); - return; - } - - final suggested = session.title.endsWith('.sql') - ? session.title - : '${session.title}.sql'; - final location = await getSaveLocation( - acceptedTypeGroups: const [ - XTypeGroup(label: 'SQL', extensions: ['sql']), - ], - suggestedName: suggested, - ); - final path = location?.path; - if (path == null || path.isEmpty) return; - await File(path).writeAsString(session.controller.text); - if (!mounted) return; - _invalidatePane(session); - setState(() { - session.title = File(path).uri.pathSegments.last; - session.markSaved(newFilePath: path); - }); - showAppToast( - context: context, - message: 'Saved to ${session.title}', - variant: AppToastVariant.success, - ); - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to save SQL file: $e', - variant: AppToastVariant.error, - ); - } - } - @override material.Widget build(material.BuildContext context) { - return Actions( - actions: >{ - NewSqlIntent: CallbackAction( - onInvoke: (intent) { - _addNewTab(); - return null; - }, - ), - CloseSqlTabIntent: CallbackAction( - onInvoke: (intent) { - if (_sessions.length > 1) { - unawaited(_closeTab(_activeSessionIndex)); - } - return null; - }, - ), - NextSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _nextTab(); - return null; - }, - ), - PrevSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _prevTab(); - return null; - }, - ), - OpenSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_openSqlFile()); - return null; - }, - ), - SaveSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_saveSqlFile()); - return null; - }, - ), - }, - child: material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): _prevTab, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - }, - child: material.Focus( - autofocus: true, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - SqlQueryTabBar( - sessions: _sessions, - selectedIndex: _activeSessionIndex, - onSelect: (index) => setState(() => _activeSessionIndex = index), - onAdd: _addNewTab, - onClose: _sessions.length > 1 - ? (index) => unawaited(_closeTab(index)) - : null, - ), - material.Expanded( - child: material.IndexedStack( - index: _activeSessionIndex, - children: [ - for (final session in _sessions) - _paneCache.putIfAbsent( - session.id, - () => _buildSessionPane(context, session), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - material.Widget _buildSessionPane( - material.BuildContext context, - SqlQueryTabSession session, - ) { - final theme = Theme.of(context); - return VerticalSplitPane( - key: material.ValueKey(session.id), - fraction: session.topFraction, - maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - sessionDatabase: _effectiveSessionDatabase(), - onExecute: session.running ? null : () => _execute(session), - running: session.running, - autocommit: _autocommit, - onAutocommitChanged: (v) { - _invalidateAllPanes(); - setState(() => _autocommit = v); - }, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !session.running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: _effectiveSessionDatabase(), - sqlController: session.controller, - onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), - ); - } - : null, - txOpen: _txOpen, - onBegin: session.running ? null : () => _runTxCommand('BEGIN'), - onCommit: session.running ? null : () => _runTxCommand('COMMIT'), - onRollback: - session.running ? null : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: session.controller, - fontSize: _editorFontSize, - ), - ), - ], - ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: session.columns, - rows: session.rows, - errorMessage: session.error, - isLoading: session.running, - affectedRows: session.affectedRows, - statusLine: session.statusLine, - stagingBuffer: session.stagingBuffer, - columnDataTypes: session.resultGridColumnDataTypes, - onApplyChanges: widget.isReadOnly || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - ) - ? null - : () => _applyStagedChanges(session), - isSaving: session.savingChanges, - ), - ), - ], - ), - ); - } -} - -class _SqlToolbar extends material.StatelessWidget { - const _SqlToolbar({ - required this.sessionDatabase, - required this.onExecute, - required this.running, - required this.autocommit, - required this.onAutocommitChanged, - required this.queryTimeoutSeconds, - required this.onQueryTimeoutChanged, - required this.onOpenPreferences, - this.onOpenHistory, - required this.txOpen, - required this.onBegin, - required this.onCommit, - required this.onRollback, - }); - - /// Effective PostgreSQL database for queries in this tab (from the connection profile). - final String sessionDatabase; - final Future Function()? onExecute; - final bool running; - final bool autocommit; - final void Function(bool) onAutocommitChanged; - final int? queryTimeoutSeconds; - final void Function(int?) onQueryTimeoutChanged; - final VoidCallback onOpenPreferences; - final VoidCallback? onOpenHistory; - final bool? txOpen; - final void Function()? onBegin; - final void Function()? onCommit; - final void Function()? onRollback; - - String _txLabel() { - if (txOpen == null) return 'Transaction: —'; - return txOpen! ? 'Transaction: open' : 'Transaction: none'; - } - - @override - material.Widget build(material.BuildContext context) { - final accent = context.workbench.accent; - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - const Text('Query').semiBold().small(), - Text('DB: $sessionDatabase').muted().small(), - Text(_txLabel()).muted().small(), - OutlineButton( - size: ButtonSize.small, - onPressed: onOpenHistory, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, - ), - child: const Text('History'), - ), - OutlineButton( - onPressed: onExecute, - leading: running - ? QueryaSpinner( - size: QueryaSpinnerSize.sm, - color: accent, - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), - ), - ], - ), - const Gap(8), - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Autocommit').small(), - const Gap(6), - material.Switch( - value: autocommit, - onChanged: running ? null : onAutocommitChanged, - ), - ], - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Stmt timeout').small(), - const Gap(6), - SqlStatementTimeoutDropdown( - value: queryTimeoutSeconds, - onChanged: onQueryTimeoutChanged, - enabled: !running, - ), - const Gap(4), - IconButton.ghost( - onPressed: running ? null : onOpenPreferences, - icon: material.Icon( - material.Icons.settings_rounded, - size: 20, - color: accent, - ), - ), - ], - ), - OutlineButton( - onPressed: onBegin, - child: const Text('Begin'), - ), - OutlineButton( - onPressed: onCommit, - child: const Text('Commit'), - ), - OutlineButton( - onPressed: onRollback, - child: const Text('Rollback'), - ), - ], - ), - ], - ), + return GenericSqlWorkspace( + key: _workspaceKey, + connectionRow: widget.connectionRow, + delegate: _delegate, + dialect: SqlDialect.postgres, + sessionPrefix: 'pg', + transactionOpenNotifier: widget.transactionOpenNotifier, + isReadOnly: widget.isReadOnly, + supportsAutocommit: true, + initialAutocommit: _autocommit, + onAutocommitChanged: (v) => _autocommit = v, + supportsStmtTimeout: true, + getStoredTimeoutSeconds: () => + AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), + setStoredTimeoutSeconds: (v) => + AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v), + effectiveDatabaseName: _effectiveSessionDatabase, ); } } diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index aa51adcf..a3c596af 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,986 +1,287 @@ import 'dart:async'; -import 'dart:io'; + import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; -import 'package:file_selector/file_selector.dart'; -import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; -import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; -import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; -import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; -import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; -import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; -import 'package:querya_desktop/features/settings/preferences_dialog.dart'; -import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; -import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; -import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Ad-hoc SQL editor + results for SQLite. -class SqliteSqlWorkspace extends material.StatefulWidget { - const SqliteSqlWorkspace({ - super.key, +/// Execution delegate for SQLite connections. +class SqliteSqlExecutionDelegate extends SqlExecutionDelegate { + SqliteSqlExecutionDelegate({ required this.connectionRow, - this.transactionOpenNotifier, - this.isReadOnly = false, + required this.isReadOnly, }); final ConnectionRow connectionRow; final bool isReadOnly; - /// Updated when transaction state changes (for tab-switch warnings). - final material.ValueNotifier? transactionOpenNotifier; - - @override - material.State createState() => - _SqliteSqlWorkspaceState(); -} - -class _SqliteSqlWorkspaceState extends material.State { - final List _sessions = []; - int _activeSessionIndex = 0; - int _nextSessionId = 1; - - /// Built session panes, keyed by [SqlQueryTabSession.id]. - /// - /// Switching tabs only changes [_activeSessionIndex]; reusing the same - /// widget instance for untouched sessions lets Flutter's element diffing - /// (`identical(oldWidget, newWidget)`) skip rebuilding their subtrees, so a - /// switch only rebuilds the tab strip and swaps the visible `IndexedStack` - /// child instead of rebuilding every tab's editor and results grid. - final Map _paneCache = {}; - - void _invalidatePane(SqlQueryTabSession session) => - _paneCache.remove(session.id); - - void _invalidateAllPanes() => _paneCache.clear(); - - /// Counts calls to [_buildSessionPane], i.e. pane cache misses. - @material.visibleForTesting - int paneBuildCount = 0; - - SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; - - @material.visibleForTesting - SqlQueryTabSession get activeSession => _activeSession; - - /// Forces the active tab's pane to rebuild from its current session state - /// on the next frame, as if a query had just finished running in it — - /// lets tests simulate "a tab already has staged edits" without going - /// through a real query execution. - @material.visibleForTesting - void debugRebuildActivePane() { - _invalidatePane(_activeSession); - setState(() {}); - } - SqliteLease? _lease; - bool? _txOpen; - int? _queryTimeoutSeconds; + SqliteLease? get lease => _lease; - int _resultMaxRows = kDefaultSqlResultMaxRows; - int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; - double _editorFontSize = kDefaultSqlEditorFontSize; - - late final VoidCallback _appSettingsListener; - - @override - void initState() { - super.initState(); - _sessions.add( - SqlQueryTabSession( - id: 'sqlite_tab_1', - title: 'Query 1', - ), + Future ensureLease() async { + if (_lease != null && _lease!.connection.isConnected) return; + _lease?.release(); + _lease = null; + final lease = await SqliteService.instance.acquire( + connectionRow, + mode: isReadOnly + ? SqliteSessionMode.readOnly + : SqliteSessionMode.readWrite, ); - _appSettingsListener = () { - unawaited(_loadWorkspaceSettings()); - }; - SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); - material.WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_loadWorkspaceSettings()); - _registerSqlEditorCommands(); - }); + _lease = lease; } - void _registerSqlEditorCommands() { - if (!mounted) return; - SqlEditorCommandBridge.instance.register( - connectionId: widget.connectionRow.id, - onNew: _addNewTab, - onOpen: () => unawaited(_openSqlFile()), - onSave: () => unawaited(_saveSqlFile()), - onCloseTab: () { - if (_sessions.length > 1) { - unawaited(_closeTab(_activeSessionIndex)); - } - }, - onNextTab: _nextTab, - onPrevTab: _prevTab, - onFormat: () { - _activeSession.formatSql(); - _invalidatePane(_activeSession); - setState(() {}); - }, - onClear: () { - _activeSession.clearSql(); - _invalidatePane(_activeSession); - setState(() {}); - }, - onExecute: () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - onOpenWithContent: (sql, filePath, title) { - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: sql, - selection: material.TextSelection.collapsed(offset: sql.length), - ); - session.title = title; - session.filePath = filePath; - _invalidatePane(session); - setState(() {}); - } else { - _addNewTab(initialSql: sql, title: title, filePath: filePath); - } - }, - ); + void dropLease() { + _lease?.release(); + _lease = null; } - void _addNewTab({String? initialSql, String? title, String? filePath}) { - setState(() { - _nextSessionId++; - final session = SqlQueryTabSession( - id: 'sqlite_tab_$_nextSessionId', - title: title ?? 'Query $_nextSessionId', - initialSql: initialSql, - filePath: filePath, - initialFraction: - _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65, - ); - _sessions.add(session); - _activeSessionIndex = _sessions.length - 1; - }); - } + @override + bool get supportsTransactions => true; - Future _closeTab(int index) async { - if (index < 0 || index >= _sessions.length) return; - if (_sessions.length <= 1) return; - final session = _sessions[index]; - if (session.isDirty) { - final hasDirtyStaging = - session.stagingBuffer != null && session.stagingBuffer!.isDirty; - final hasUnsavedText = session.isModified || - (session.filePath == null && - session.controller.text.trim().isNotEmpty); - final String message; - if (hasDirtyStaging && hasUnsavedText) { - message = - 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; - } else if (hasDirtyStaging) { - message = - 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; - } else { - message = - 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; - } - final confirmed = await showUnsavedTabChangesDialog( - context: context, - tabTitle: session.title, - message: message, - ); - if (confirmed != true) return; + @override + Future checkTransactionOpen() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + return null; } - if (!mounted) return; - setState(() { - _sessions.removeAt(index); - session.dispose(); - _paneCache.remove(session.id); - if (_activeSessionIndex >= _sessions.length) { - _activeSessionIndex = _sessions.length - 1; - } - }); - } - - void _nextTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = (_activeSessionIndex + 1) % _sessions.length; - }); + return conn.inOpenTransaction(); } - void _prevTab() { - if (_sessions.length <= 1) return; - setState(() { - _activeSessionIndex = - (_activeSessionIndex - 1 + _sessions.length) % _sessions.length; - }); + @override + Future runTransactionCommand(String command, {Duration? timeout}) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to SQLite.'); + } + await conn.executeWithTimeout(command, timeout: timeout); } @override - void didUpdateWidget(covariant SqliteSqlWorkspace oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.isReadOnly != widget.isReadOnly) { - _lease?.release(); - _lease = null; - // Cached panes closed over the old widget.isReadOnly in their - // onApplyChanges callback (#1007): rebuild every tab so Save reflects - // the lock immediately instead of only on that tab's next query run. - _invalidateAllPanes(); + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to SQLite.'); } - } - Future _loadWorkspaceSettings() async { - final t = await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); - final rows = await AppSettings.instance.getSqlResultMaxRows(); - final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); - final font = await AppSettings.instance.getSqlEditorFontSize(); - if (!mounted) return; - _invalidateAllPanes(); - setState(() { - _queryTimeoutSeconds = t; - _resultMaxRows = rows; - _historyMaxEntries = hist; - _editorFontSize = font; - }); - } + final cap = limit ?? kDefaultSqlResultMaxRows; + final effectiveSql = sqliteSqlIsReadOnlyQuery(sql) + ? injectSqlLimit(sql, cap) + : sql; - void _onStmtTimeoutChanged(int? v) { - _invalidateAllPanes(); - setState(() => _queryTimeoutSeconds = v); - unawaited(AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v)); - } + final results = await conn.executeWithTimeout(effectiveSql, timeout: timeout); - Future _ensureLease() async { - if (_lease != null && _lease!.connection.isConnected) return; - _lease?.release(); - _lease = null; - final lease = await SqliteService.instance.acquire( - widget.connectionRow, - mode: widget.isReadOnly - ? SqliteSessionMode.readOnly - : SqliteSessionMode.readWrite, - ); - if (!mounted) { - lease.release(); - return; + final cols = []; + if (results.isNotEmpty) { + cols.addAll(results.first.keys); + } else if (sqliteSqlIsReadOnlyQuery(sql)) { + cols.addAll(await conn.inferQueryColumns(sql)); } - _lease = lease; - } - void _notifyTransactionOpen() { - widget.transactionOpenNotifier?.value = _txOpen; + final truncated = results.length > cap; + final limitCount = truncated ? cap : results.length; + final injectedLimit = effectiveSql != sql; + + final rawRows = results.take(limitCount).map((row) { + return cols + .map((col) => sqliteResultCellToDisplayString(row[col])) + .toList(); + }).toList(); + + final outRows = await convertResultRowsToStringsAdaptive(rawRows); + + String? statusMsg; + if (cols.isEmpty && outRows.isEmpty) { + statusMsg = 'Command completed.'; + } else if (truncated || (injectedLimit && results.length >= cap)) { + statusMsg = 'Showing first $cap row(s) (result capped).'; + } else { + statusMsg = '${results.length} row(s).'; + } + + return SqlExecutionResult( + columns: cols, + rows: outRows, + statusMessage: statusMsg, + isTruncated: truncated || (injectedLimit && results.length >= cap), + ); } - Future _refreshTxStatus() async { + @override + Future explainQuery(String sql) async { + await ensureLease(); final conn = _lease?.connection; if (conn == null || !conn.isConnected) { - if (mounted) setState(() => _txOpen = null); - _notifyTransactionOpen(); - return; + throw StateError('Could not connect to SQLite.'); } - final v = await conn.inOpenTransaction(); - if (mounted) setState(() => _txOpen = v); - _notifyTransactionOpen(); + final res = await conn.executeWithTimeout('EXPLAIN QUERY PLAN $sql'); + return res.map((r) => r.values.join(' | ')).join('\n'); } - Duration? _statementTimeout() => _queryTimeoutSeconds == null - ? null - : Duration(seconds: _queryTimeoutSeconds!); - @override - void dispose() { - SqlEditorCommandBridge.instance - .unregister(connectionId: widget.connectionRow.id); - SqlWorkspaceSettingsRevision.listenable - .removeListener(_appSettingsListener); - _lease?.release(); - for (final s in _sessions) { - s.dispose(); - } - _sessions.clear(); - _paneCache.clear(); - super.dispose(); + Future cancelQuery() async { + await _lease?.connection.forceClose(); + dropLease(); } - Future _execute([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - final selection = session.controller.selection; - String userSql; - if (selection.isValid && !selection.isCollapsed) { - userSql = selection.textInside(session.controller.text).trim(); - } else { - userSql = session.controller.text.trim(); + @override + Future resolveTableSchema( + String userSql, + List columns, + ) async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + return SqlResultGridSchema.none; } - if (userSql.isEmpty) return; + final target = SqlTableTargetExtractor.extract(userSql); + if (target != null && columns.isNotEmpty) { + var isView = false; + try { + final kind = await conn.execute( + 'SELECT type FROM sqlite_master WHERE name = ?', + [target.tableName], + ); + isView = kind.isNotEmpty && kind.first['type'] == 'view'; + } catch (_) {} - final safeToProceed = await confirmDiscardTableEditsIfDirty( - context: context, - buffer: session.stagingBuffer, - tableTitle: session.title, - ); - if (!safeToProceed) return; - if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { - session.stagingBuffer?.dispose(); - session.stagingBuffer = null; + return SqlResultGridSchema.fromLoad( + await loadTableViewSchema( + () => conn.getTableSchema(table: target.tableName), + ), + sqliteImplicitRowid: !isView, + ); } + return SqlResultGridSchema.none; + } - final confirmDestructive = - await AppSettings.instance.getConfirmDestructiveOperations(); - if (confirmDestructive) { - final inspection = DestructiveSqlDetector.inspect(userSql); - if (inspection.isDestructive) { - if (!mounted) return; - final confirmed = await showDestructiveQueryDialog( - context: context, - result: inspection, - sql: userSql, - connectionName: widget.connectionRow.name, - ); - if (confirmed != true) return; - } + @override + Future applyStagedMutations({ + required TableMutationPlan plan, + Duration? timeout, + }) async { + await ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to SQLite.'); } - _invalidatePane(session); - setState(() { - session.running = true; - session.error = null; - session.columns = []; - session.rows = []; - session.affectedRows = null; - session.statusLine = null; - session.resultGridPrimaryKeys = const []; - session.resultGridColumnDataTypes = null; - session.resultGridColumnMeta = null; - }); - QueryaShellStatus.instance.beginBusy(message: 'Running query…'); - final sw = Stopwatch()..start(); - - try { - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Could not connect to SQLite.'; - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - return; + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); } + }); + } - // Bound SELECT/WITH-SELECT/VALUES at the engine before materializing rows. - // WITH … INSERT and assignment PRAGMA skip LIMIT (they are writes). - final cap = _resultMaxRows; - final sql = sqliteSqlIsReadOnlyQuery(userSql) - ? injectSqlLimit(userSql, cap) - : userSql; - final results = - await conn.executeWithTimeout(sql, timeout: _statementTimeout()); - - if (!mounted) return; - - final cols = []; - if (results.isNotEmpty) { - cols.addAll(results.first.keys); - } else if (sqliteSqlIsReadOnlyQuery(userSql)) { - cols.addAll(await conn.inferQueryColumns(userSql)); - } - - final truncated = results.length > cap; - final limitCount = truncated ? cap : results.length; - final injectedLimit = sql != userSql; - - final rawRows = results.take(limitCount).map((row) { - return cols - .map((col) => sqliteResultCellToDisplayString(row[col])) - .toList(); - }).toList(); - - final outRows = await convertResultRowsToStringsAdaptive(rawRows); - - final target = SqlTableTargetExtractor.extract(userSql); - var gridSchema = SqlResultGridSchema.none; - if (target != null && cols.isNotEmpty) { - // Views have no rowid, so they never fall back to it. - var isView = false; - try { - final kind = await conn.execute( - 'SELECT type FROM sqlite_master WHERE name = ?', - [target.tableName], - ); - isView = kind.isNotEmpty && kind.first['type'] == 'view'; - } catch (_) { - // Unknown kind: treat as a table (the schema load surfaces real errors). - } - gridSchema = SqlResultGridSchema.fromLoad( - await loadTableViewSchema( - () => conn.getTableSchema(table: target.tableName), - ), - sqliteImplicitRowid: !isView, - ); - } - final pks = gridSchema.primaryKeys; - final editHint = gridSchema.editHint(cols); - final canSave = sqlResultGridSaveEnabled( - sql: userSql, - resultColumns: cols, - primaryKeys: pks, - ); - - _invalidatePane(session); - setState(() { - session.columns = cols; - session.rows = outRows; - session.affectedRows = null; - session.lastExecutedSql = userSql; - session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = gridSchema.columnDataTypes; - session.resultGridColumnMeta = gridSchema.columnMeta; - session.stagingBuffer?.dispose(); - session.stagingBuffer = canSave - ? DataGridStagingBuffer( - columns: cols, - rows: outRows, - primaryKeys: pks, - ) - : null; - if (cols.isEmpty && outRows.isEmpty) { - session.statusLine = 'Command completed.'; - } else if (truncated || (injectedLimit && results.length >= cap)) { - session.statusLine = withEditHint( - 'Showing first $cap row(s) (result capped).', - editHint, - ); - } else { - session.statusLine = - withEditHint('${results.length} row(s).', editHint); - } - session.running = false; - }); - - sw.stop(); - QueryaShellStatus.instance.reportQueryResult( - duration: sw.elapsed, - rowCount: outRows.length, - columnCount: cols.length, - message: session.statusLine, - ); - - final cid = widget.connectionRow.id; - if (cid != null) { - unawaited( - LocalDb.instance.recordSqlQueryHistory( - connectionId: cid, - databaseName: widget.connectionRow.databaseName, - sqlText: userSql, - maxEntries: _historyMaxEntries, - ), - ); - } - } on TimeoutException catch (e) { - unawaited(_lease?.connection.forceClose()); - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = 'Query timed out: ${e.message ?? e}'; - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() { - session.error = e.toString(); - session.running = false; - }); - QueryaShellStatus.instance.endBusy(); - } - } finally { - await _refreshTxStatus(); - } + @override + void dispose() { + dropLease(); } +} - Future _applyStagedChanges([SqlQueryTabSession? targetSession]) async { - final session = targetSession ?? _activeSession; - if (widget.isReadOnly || - session.stagingBuffer == null || - !session.stagingBuffer!.isDirty || - session.savingChanges) { - return; - } - final target = session.lastExecutedSql != null - ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) - : null; - if (target == null || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - )) { - return; - } +/// Ad-hoc SQL editor + results for SQLite. +class SqliteSqlWorkspace extends material.StatefulWidget { + const SqliteSqlWorkspace({ + super.key, + required this.connectionRow, + this.transactionOpenNotifier, + this.isReadOnly = false, + }); - _invalidatePane(session); - setState(() => session.savingChanges = true); - try { - final plan = session.stagingBuffer!.generateMutationPlan( - dialect: SqlDialect.sqlite, - tableName: target.tableName, - schema: target.schema, - primaryKeys: session.resultGridPrimaryKeys, - columnDataTypes: session.resultGridColumnDataTypes, - columnMeta: session.resultGridColumnMeta, - ); - if (plan.isEmpty) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - return; - } + final ConnectionRow connectionRow; + final bool isReadOnly; - final confirmed = await showDmlPreviewDialog( - context: context, - plan: plan, - ); - if (confirmed != true) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - return; - } + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; - await _ensureLease(); - final conn = _lease?.connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to SQLite.'); - } + @override + material.State createState() => + _SqliteSqlWorkspaceState(); +} - await conn.runInTransaction(() async { - for (final stmt in plan.statements) { - expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); - } - }); - await _refreshTxStatus(); - - if (!mounted) return; - final newRows = session.stagingBuffer!.committedRows; - session.stagingBuffer?.dispose(); - _invalidatePane(session); - setState(() { - session.rows = newRows; - session.stagingBuffer = DataGridStagingBuffer( - columns: session.columns, - rows: session.rows, - primaryKeys: session.resultGridPrimaryKeys, - ); - session.savingChanges = false; - }); - } catch (e) { - if (mounted) { - _invalidatePane(session); - setState(() => session.savingChanges = false); - await showTableViewSaveFailedDialog(context: context, error: e); - } - } - } +class _SqliteSqlWorkspaceState extends material.State { + late SqliteSqlExecutionDelegate _delegate; + final material.GlobalKey _workspaceKey = + material.GlobalKey(); - Future _openSqlFile() async { - try { - final file = await openFile( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'SQL query', - extensions: ['sql'], - ), - ], - ); - if (file == null) return; - final text = await file.readAsString(); - if (!mounted) return; - final session = _activeSession; - if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { - session.controller.value = material.TextEditingValue( - text: text, - selection: material.TextSelection.collapsed(offset: text.length), - ); - session.title = file.name; - session.markSaved(newFilePath: file.path); - _invalidatePane(session); - setState(() {}); - } else { - _addNewTab(initialSql: text, title: file.name, filePath: file.path); - } - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to open SQL file: $e', - variant: AppToastVariant.error, - ); + @material.visibleForTesting + int get paneBuildCount => _workspaceKey.currentState?.paneBuildCount ?? 0; + + @material.visibleForTesting + set paneBuildCount(int value) { + final ws = _workspaceKey.currentState; + if (ws != null) { + ws.paneBuildCount = value; } } - Future _saveSqlFile() async { - try { - final session = _activeSession; - final existingPath = session.filePath; - if (existingPath != null && existingPath.isNotEmpty) { - await File(existingPath).writeAsString(session.controller.text); - session.markSaved(); - if (!mounted) return; - showAppToast( - context: context, - message: 'Saved ${session.title}', - variant: AppToastVariant.success, - ); - return; - } + @material.visibleForTesting + SqlQueryTabSession get activeSession => _workspaceKey.currentState!.activeSession; - final suggested = session.title.endsWith('.sql') - ? session.title - : '${session.title}.sql'; - final location = await getSaveLocation( - acceptedTypeGroups: const [ - XTypeGroup(label: 'SQL', extensions: ['sql']), - ], - suggestedName: suggested, - ); - final path = location?.path; - if (path == null || path.isEmpty) return; - await File(path).writeAsString(session.controller.text); - if (!mounted) return; - _invalidatePane(session); - setState(() { - session.title = File(path).uri.pathSegments.last; - session.markSaved(newFilePath: path); - }); - showAppToast( - context: context, - message: 'Saved to ${session.title}', - variant: AppToastVariant.success, - ); - } catch (e) { - if (!mounted) return; - showAppToast( - context: context, - message: 'Failed to save SQL file: $e', - variant: AppToastVariant.error, - ); - } + @material.visibleForTesting + void debugRebuildActivePane() { + _workspaceKey.currentState?.debugRebuildActivePane(); } @override - material.Widget build(material.BuildContext context) { - return Actions( - actions: >{ - NewSqlIntent: CallbackAction( - onInvoke: (intent) { - _addNewTab(); - return null; - }, - ), - CloseSqlTabIntent: CallbackAction( - onInvoke: (intent) { - if (_sessions.length > 1) { - unawaited(_closeTab(_activeSessionIndex)); - } - return null; - }, - ), - NextSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _nextTab(); - return null; - }, - ), - PrevSqlTabIntent: CallbackAction( - onInvoke: (intent) { - _prevTab(); - return null; - }, - ), - OpenSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_openSqlFile()); - return null; - }, - ), - SaveSqlIntent: CallbackAction( - onInvoke: (intent) { - unawaited(_saveSqlFile()); - return null; - }, - ), - }, - child: material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, - control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): - _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, - control: true): () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): - () { - if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); - }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): - _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, - control: true, shift: true): _prevTab, - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.enter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.numpadEnter, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - control: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - const material.SingleActivator( - LogicalKeyboardKey.keyR, - meta: true, - ): () { - if (!_activeSession.running) unawaited(_execute(_activeSession)); - }, - }, - child: material.Focus( - autofocus: true, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - SqlQueryTabBar( - sessions: _sessions, - selectedIndex: _activeSessionIndex, - onSelect: (index) => - setState(() => _activeSessionIndex = index), - onAdd: _addNewTab, - onClose: _sessions.length > 1 - ? (index) => unawaited(_closeTab(index)) - : null, - ), - material.Expanded( - child: material.IndexedStack( - index: _activeSessionIndex, - children: [ - for (final session in _sessions) - _paneCache.putIfAbsent( - session.id, - () => _buildSessionPane(context, session), - ), - ], - ), - ), - ], - ), - ), - ), + void initState() { + super.initState(); + _delegate = SqliteSqlExecutionDelegate( + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, ); } - material.Widget _buildSessionPane( - material.BuildContext context, - SqlQueryTabSession session, - ) { - paneBuildCount++; - final theme = Theme.of(context); - return VerticalSplitPane( - key: material.ValueKey(session.id), - fraction: session.topFraction, - maxFraction: 0.85, - top: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqliteSqlToolbar( - onExecute: session.running ? null : () => _execute(session), - running: session.running, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !session.running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: widget.connectionRow.databaseName, - sqlController: session.controller, - onOpenInNewTab: (sql) => _addNewTab(initialSql: sql), - ); - } - : null, - ), - const Divider(height: 1), - material.Expanded( - child: QueryEditorTab( - controller: session.controller, - fontSize: _editorFontSize, - ), - ), - ], - ), - bottom: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - material.Expanded( - child: ResultsTab( - columns: session.columns, - rows: session.rows, - errorMessage: session.error, - isLoading: session.running, - affectedRows: session.affectedRows, - statusLine: session.statusLine, - stagingBuffer: session.stagingBuffer, - columnDataTypes: session.resultGridColumnDataTypes, - onApplyChanges: widget.isReadOnly || - !sqlResultGridSaveEnabled( - sql: session.lastExecutedSql, - resultColumns: session.columns, - primaryKeys: session.resultGridPrimaryKeys, - ) - ? null - : () => _applyStagedChanges(session), - isSaving: session.savingChanges, - ), - ), - ], - ), - ); + @override + void didUpdateWidget(covariant SqliteSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly || + oldWidget.connectionRow.id != widget.connectionRow.id) { + _delegate.dispose(); + _delegate = SqliteSqlExecutionDelegate( + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, + ); + _workspaceKey.currentState?.invalidateAllPanes(); + } } -} -class _SqliteSqlToolbar extends material.StatelessWidget { - const _SqliteSqlToolbar({ - required this.onExecute, - required this.running, - required this.queryTimeoutSeconds, - required this.onQueryTimeoutChanged, - required this.onOpenPreferences, - this.onOpenHistory, - }); - - final Future Function()? onExecute; - final bool running; - final int? queryTimeoutSeconds; - final void Function(int?) onQueryTimeoutChanged; - final VoidCallback onOpenPreferences; - final VoidCallback? onOpenHistory; + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } @override material.Widget build(material.BuildContext context) { - final accent = context.workbench.accent; - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - const Text('Query').semiBold().small(), - OutlineButton( - size: ButtonSize.small, - onPressed: onOpenHistory, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, - ), - child: const Text('History'), - ), - OutlineButton( - onPressed: onExecute, - leading: running - ? QueryaSpinner( - size: QueryaSpinnerSize.sm, - color: accent, - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), - ), - ], - ), - const Gap(8), - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - const Text('Stmt timeout').small(), - SqlStatementTimeoutDropdown( - value: queryTimeoutSeconds, - onChanged: onQueryTimeoutChanged, - enabled: !running, - ), - IconButton.ghost( - onPressed: running ? null : onOpenPreferences, - icon: material.Icon( - material.Icons.settings_rounded, - size: 20, - color: accent, - ), - ), - ], - ), - ], - ), + return GenericSqlWorkspace( + key: _workspaceKey, + connectionRow: widget.connectionRow, + delegate: _delegate, + dialect: SqlDialect.sqlite, + sessionPrefix: 'sqlite', + transactionOpenNotifier: widget.transactionOpenNotifier, + isReadOnly: widget.isReadOnly, + supportsAutocommit: false, + supportsStmtTimeout: true, + getStoredTimeoutSeconds: () => + AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), + setStoredTimeoutSeconds: (v) => + AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v), + effectiveDatabaseName: () => widget.connectionRow.databaseName ?? '', ); } } diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart new file mode 100644 index 00000000..89a2c0c4 --- /dev/null +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -0,0 +1,992 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; +import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; +import 'package:querya_desktop/features/workspace/query_editor_tab.dart'; +import 'package:querya_desktop/features/workspace/results_tab.dart'; +import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; +import 'package:querya_desktop/features/workspace/sql_query_history_dialog.dart'; +import 'package:querya_desktop/features/workspace/sql_query_tab_bar.dart'; +import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Reusable base SQL workspace supporting multi-tab editing, statement execution, +/// destructive query confirmations, unsaved changes guards, DML staging and result grids. +class GenericSqlWorkspace extends material.StatefulWidget { + const GenericSqlWorkspace({ + super.key, + required this.connectionRow, + required this.delegate, + required this.dialect, + this.sessionPrefix = 'query', + this.initialSql, + this.initialTabTitle, + this.transactionOpenNotifier, + this.isReadOnly = false, + this.supportsAutocommit = false, + this.initialAutocommit = true, + this.onAutocommitChanged, + this.supportsStmtTimeout = true, + this.getStoredTimeoutSeconds, + this.setStoredTimeoutSeconds, + this.effectiveDatabaseName, + this.extraToolbarTrailing, + this.errorActionBuilder, + this.headerBadge, + }); + + final ConnectionRow connectionRow; + final SqlExecutionDelegate delegate; + final SqlDialect dialect; + final String sessionPrefix; + final String? initialSql; + final String? initialTabTitle; + final material.ValueNotifier? transactionOpenNotifier; + final bool isReadOnly; + + final bool supportsAutocommit; + final bool initialAutocommit; + final void Function(bool value)? onAutocommitChanged; + + final bool supportsStmtTimeout; + final Future Function()? getStoredTimeoutSeconds; + final Future Function(int? value)? setStoredTimeoutSeconds; + + final String Function()? effectiveDatabaseName; + final material.Widget? Function(material.BuildContext context, SqlQueryTabSession session)? extraToolbarTrailing; + final material.Widget? Function(material.BuildContext context, SqlQueryTabSession session)? errorActionBuilder; + final material.Widget? headerBadge; + + @override + material.State createState() => GenericSqlWorkspaceState(); +} + +class GenericSqlWorkspaceState extends material.State { + final List _sessions = []; + int _activeSessionIndex = 0; + int _nextSessionId = 1; + + SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; + + SqlQueryTabSession get activeSession => _activeSession; + + final Map _paneCache = {}; + + void invalidatePane(SqlQueryTabSession session) => + _paneCache.remove(session.id); + + void invalidateAllPanes() => _paneCache.clear(); + + int paneBuildCount = 0; + + void debugRebuildActivePane() { + invalidatePane(_activeSession); + setState(() {}); + } + + bool? _txOpen; + bool _autocommit = true; + int? _queryTimeoutSeconds; + + int _resultMaxRows = kDefaultSqlResultMaxRows; + int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; + double _editorFontSize = kDefaultSqlEditorFontSize; + + late final VoidCallback _appSettingsListener; + + String get effectiveDatabase => + widget.effectiveDatabaseName?.call() ?? + widget.connectionRow.databaseName ?? + ''; + + @override + void initState() { + super.initState(); + _autocommit = widget.initialAutocommit; + _sessions.add( + SqlQueryTabSession( + id: '${widget.sessionPrefix}_tab_1', + title: widget.initialTabTitle ?? 'Query 1', + initialSql: widget.initialSql, + ), + ); + _appSettingsListener = () { + unawaited(_loadWorkspaceSettings()); + }; + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_loadWorkspaceSettings()); + _registerSqlEditorCommands(); + }); + } + + @override + void didUpdateWidget(covariant GenericSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly) { + invalidateAllPanes(); + } + } + + @override + void dispose() { + SqlEditorCommandBridge.instance + .unregister(connectionId: widget.connectionRow.id); + SqlWorkspaceSettingsRevision.listenable + .removeListener(_appSettingsListener); + final anyRunning = _sessions.any((s) => s.running); + if (anyRunning) { + unawaited(widget.delegate.cancelQuery()); + } + for (final s in _sessions) { + s.dispose(); + } + _sessions.clear(); + _paneCache.clear(); + widget.delegate.dispose(); + super.dispose(); + } + + void _registerSqlEditorCommands() { + if (!mounted) return; + SqlEditorCommandBridge.instance.register( + connectionId: widget.connectionRow.id, + onNew: addNewTab, + onOpen: () => unawaited(openSqlFile()), + onSave: () => unawaited(saveSqlFile()), + onExecute: () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + onCloseTab: () { + if (_sessions.length > 1) unawaited(closeTab(_activeSessionIndex)); + }, + onNextTab: nextTab, + onPrevTab: prevTab, + onFormat: () { + _activeSession.formatSql(); + invalidatePane(_activeSession); + setState(() {}); + }, + onClear: () { + _activeSession.clearSql(); + invalidatePane(_activeSession); + setState(() {}); + }, + onOpenWithContent: (sql, filePath, title) { + if (!mounted) return; + final session = _activeSession; + if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { + session.controller.value = material.TextEditingValue( + text: sql, + selection: material.TextSelection.collapsed(offset: sql.length), + ); + session.title = title; + session.filePath = filePath; + invalidatePane(session); + setState(() {}); + } else { + addNewTab(initialSql: sql, title: title, filePath: filePath); + } + }, + ); + } + + void addNewTab({String? initialSql, String? title, String? filePath}) { + setState(() { + _nextSessionId++; + final session = SqlQueryTabSession( + id: '${widget.sessionPrefix}_tab_${DateTime.now().millisecondsSinceEpoch}_$_nextSessionId', + title: title ?? 'Query $_nextSessionId', + initialSql: initialSql ?? '', + filePath: filePath, + initialFraction: + _sessions.isNotEmpty ? _activeSession.topFraction.value : 0.65, + ); + _sessions.add(session); + _activeSessionIndex = _sessions.length - 1; + }); + } + + Future closeTab(int index) async { + if (index < 0 || index >= _sessions.length) return; + if (_sessions.length <= 1) return; + final session = _sessions[index]; + if (session.isDirty) { + final hasDirtyStaging = + session.stagingBuffer != null && session.stagingBuffer!.isDirty; + final hasUnsavedText = session.isModified || + (session.filePath == null && session.controller.text.trim().isNotEmpty); + final String message; + if (hasDirtyStaging && hasUnsavedText) { + message = + 'This query tab contains unsaved query text and staged database changes. Closing the tab will discard them.'; + } else if (hasDirtyStaging) { + message = + 'This query tab contains staged database changes that have not been applied yet. Closing the tab will discard these changes.'; + } else { + message = + 'This query tab contains unsaved SQL query text. Closing the tab will discard your changes.'; + } + final confirmed = await showUnsavedTabChangesDialog( + context: context, + tabTitle: session.title, + message: message, + ); + if (confirmed != true) return; + } + if (!mounted) return; + setState(() { + _sessions.removeAt(index); + session.dispose(); + _paneCache.remove(session.id); + if (_activeSessionIndex >= _sessions.length) { + _activeSessionIndex = _sessions.length - 1; + } + }); + } + + void nextTab() { + if (_sessions.length <= 1) return; + setState(() { + _activeSessionIndex = (_activeSessionIndex + 1) % _sessions.length; + }); + } + + void prevTab() { + if (_sessions.length <= 1) return; + setState(() { + _activeSessionIndex = + (_activeSessionIndex - 1 + _sessions.length) % _sessions.length; + }); + } + + Future _loadWorkspaceSettings() async { + int? t; + if (widget.getStoredTimeoutSeconds != null) { + t = await widget.getStoredTimeoutSeconds!(); + } + final rows = await AppSettings.instance.getSqlResultMaxRows(); + final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); + final font = await AppSettings.instance.getSqlEditorFontSize(); + if (!mounted) return; + invalidateAllPanes(); + setState(() { + _queryTimeoutSeconds = t; + _resultMaxRows = rows; + _historyMaxEntries = hist; + _editorFontSize = font; + }); + } + + void _onStmtTimeoutChanged(int? v) { + invalidateAllPanes(); + setState(() => _queryTimeoutSeconds = v); + if (widget.setStoredTimeoutSeconds != null) { + unawaited(widget.setStoredTimeoutSeconds!(v)); + } + } + + void _notifyTransactionOpen() { + widget.transactionOpenNotifier?.value = _txOpen; + } + + Future refreshTxStatus() async { + if (!widget.delegate.supportsTransactions) return; + final v = await widget.delegate.checkTransactionOpen(); + if (mounted) { + invalidateAllPanes(); + setState(() => _txOpen = v); + } + _notifyTransactionOpen(); + } + + Duration? get statementTimeout => _queryTimeoutSeconds == null + ? null + : Duration(seconds: _queryTimeoutSeconds!); + + Future runTxCommand(String cmd) async { + final session = _activeSession; + invalidatePane(session); + setState(() { + session.running = true; + session.error = null; + }); + try { + await widget.delegate.runTransactionCommand(cmd, timeout: statementTimeout); + if (!mounted) return; + invalidatePane(session); + setState(() { + session.columns = []; + session.rows = []; + session.affectedRows = null; + session.statusLine = 'OK: $cmd'; + session.running = false; + }); + } on TimeoutException catch (e) { + if (mounted) { + invalidatePane(session); + setState(() { + session.error = 'Query timed out: ${e.message ?? e}'; + session.running = false; + }); + } + } catch (e) { + if (mounted) { + invalidatePane(session); + setState(() { + session.error = e.toString(); + session.running = false; + }); + } + } finally { + await refreshTxStatus(); + } + } + + Future execute([SqlQueryTabSession? targetSession]) async { + final session = targetSession ?? _activeSession; + if (session.running) return; + + final selection = session.controller.selection; + String userSql; + if (selection.isValid && !selection.isCollapsed) { + userSql = selection.textInside(session.controller.text).trim(); + } else { + userSql = session.controller.text.trim(); + } + if (userSql.isEmpty) return; + + final safeToProceed = await confirmDiscardTableEditsIfDirty( + context: context, + buffer: session.stagingBuffer, + tableTitle: session.title, + ); + if (!safeToProceed) return; + if (session.stagingBuffer != null && session.stagingBuffer!.isDirty) { + session.stagingBuffer?.dispose(); + session.stagingBuffer = null; + } + + final confirmDestructive = + await AppSettings.instance.getConfirmDestructiveOperations(); + if (confirmDestructive) { + final inspection = DestructiveSqlDetector.inspect(userSql); + if (inspection.isDestructive) { + if (!mounted) return; + final confirmed = await showDestructiveQueryDialog( + context: context, + result: inspection, + sql: userSql, + connectionName: widget.connectionRow.name, + ); + if (confirmed != true) return; + } + } + + invalidatePane(session); + setState(() { + session.running = true; + session.error = null; + session.columns = []; + session.rows = []; + session.affectedRows = null; + session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; + session.resultGridColumnMeta = null; + }); + QueryaShellStatus.instance.beginBusy(message: 'Running query…'); + final sw = Stopwatch()..start(); + + try { + final result = await widget.delegate.executeQuery( + userSql, + limit: _resultMaxRows, + timeout: statementTimeout, + ); + + if (!mounted) return; + + final cols = result.columns; + final outRows = result.rows; + + final gridSchema = await widget.delegate.resolveTableSchema(userSql, cols); + final pks = gridSchema.primaryKeys; + final editHint = gridSchema.editHint(cols); + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + + invalidatePane(session); + setState(() { + session.columns = cols; + session.rows = outRows; + session.affectedRows = result.affectedRows; + session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = gridSchema.columnDataTypes; + session.resultGridColumnMeta = gridSchema.columnMeta; + session.stagingBuffer?.dispose(); + session.stagingBuffer = canSave + ? DataGridStagingBuffer( + columns: cols, + rows: outRows, + primaryKeys: pks, + ) + : null; + + if (result.statusMessage != null) { + session.statusLine = withEditHint(result.statusMessage!, editHint); + } else if (cols.isEmpty && outRows.isEmpty) { + session.statusLine = result.affectedRows != null + ? 'OK. Rows affected: ${result.affectedRows}.' + : 'Command completed.'; + } else { + final n = outRows.length; + final cap = _resultMaxRows; + session.statusLine = withEditHint( + result.isTruncated + ? 'Showing first $cap row(s) (result capped).' + : '$n row(s).', + editHint, + ); + } + session.running = false; + }); + + sw.stop(); + final duration = result.elapsed ?? sw.elapsed; + QueryaShellStatus.instance.reportQueryResult( + duration: duration, + rowCount: outRows.length, + columnCount: cols.length, + message: session.statusLine, + ); + + final cid = widget.connectionRow.id; + if (cid != null) { + unawaited( + LocalDb.instance.recordSqlQueryHistory( + connectionId: cid, + databaseName: effectiveDatabase, + sqlText: userSql, + maxEntries: _historyMaxEntries, + ), + ); + } + } on TimeoutException catch (e) { + if (mounted) { + invalidatePane(session); + setState(() { + session.error = 'Query timed out: ${e.message ?? e}'; + session.running = false; + }); + QueryaShellStatus.instance.endBusy(); + } + } catch (e) { + if (mounted) { + invalidatePane(session); + setState(() { + session.error = e.toString(); + session.running = false; + }); + QueryaShellStatus.instance.endBusy(); + } + } finally { + await refreshTxStatus(); + } + } + + Future applyStagedChanges([SqlQueryTabSession? targetSession]) async { + final session = targetSession ?? _activeSession; + if (widget.isReadOnly || + session.stagingBuffer == null || + !session.stagingBuffer!.isDirty || + session.savingChanges) { + return; + } + final target = session.lastExecutedSql != null + ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) + : null; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } + + final schemaName = target.schema ?? + (widget.connectionRow.databaseName?.trim().isNotEmpty == true + ? widget.connectionRow.databaseName!.trim() + : null); + + invalidatePane(session); + setState(() => session.savingChanges = true); + + try { + final plan = session.stagingBuffer!.generateMutationPlan( + dialect: widget.dialect, + tableName: target.tableName, + schema: schemaName, + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, + columnMeta: session.resultGridColumnMeta, + ); + if (plan.isEmpty) { + invalidatePane(session); + setState(() => session.savingChanges = false); + return; + } + + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + invalidatePane(session); + setState(() => session.savingChanges = false); + return; + } + + await widget.delegate.applyStagedMutations( + plan: plan, + timeout: statementTimeout, + ); + await refreshTxStatus(); + + if (!mounted) return; + final newRows = session.stagingBuffer!.committedRows; + session.stagingBuffer?.dispose(); + invalidatePane(session); + setState(() { + session.rows = newRows; + session.stagingBuffer = DataGridStagingBuffer( + columns: session.columns, + rows: session.rows, + primaryKeys: session.resultGridPrimaryKeys, + ); + session.savingChanges = false; + }); + } catch (e) { + if (mounted) { + invalidatePane(session); + setState(() => session.savingChanges = false); + await showTableViewSaveFailedDialog(context: context, error: e); + } + } + } + + Future openSqlFile() async { + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'SQL query', + extensions: ['sql'], + ), + ], + ); + if (file == null) return; + final text = await file.readAsString(); + if (!mounted) return; + final session = _activeSession; + if (session.controller.text.trim().isEmpty && session.rows.isEmpty) { + session.controller.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + session.title = file.name; + session.markSaved(newFilePath: file.path); + invalidatePane(session); + setState(() {}); + } else { + addNewTab(initialSql: text, title: file.name, filePath: file.path); + } + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to open SQL file: $e', + variant: AppToastVariant.error, + ); + } + } + + Future saveSqlFile() async { + try { + final session = _activeSession; + final existingPath = session.filePath; + if (existingPath != null && existingPath.isNotEmpty) { + await File(existingPath).writeAsString(session.controller.text); + session.markSaved(); + if (!mounted) return; + showAppToast( + context: context, + message: 'Saved ${session.title}', + variant: AppToastVariant.success, + ); + return; + } + + final suggested = session.title.endsWith('.sql') + ? session.title + : '${session.title}.sql'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL', extensions: ['sql']), + ], + suggestedName: suggested, + ); + final path = location?.path; + if (path == null || path.isEmpty) return; + await File(path).writeAsString(session.controller.text); + if (!mounted) return; + invalidatePane(session); + setState(() { + session.title = File(path).uri.pathSegments.last; + session.markSaved(newFilePath: path); + }); + showAppToast( + context: context, + message: 'Saved to ${session.title}', + variant: AppToastVariant.success, + ); + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to save SQL file: $e', + variant: AppToastVariant.error, + ); + } + } + + @override + material.Widget build(material.BuildContext context) { + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + addNewTab(); + return null; + }, + ), + CloseSqlTabIntent: CallbackAction( + onInvoke: (intent) { + if (_sessions.length > 1) { + unawaited(closeTab(_activeSessionIndex)); + } + return null; + }, + ), + NextSqlTabIntent: CallbackAction( + onInvoke: (intent) { + nextTab(); + return null; + }, + ), + PrevSqlTabIntent: CallbackAction( + onInvoke: (intent) { + prevTab(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(saveSqlFile()); + return null; + }, + ), + }, + child: material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { + if (_sessions.length > 1) unawaited(closeTab(_activeSessionIndex)); + }, + const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { + if (_sessions.length > 1) unawaited(closeTab(_activeSessionIndex)); + }, + const material.SingleActivator(LogicalKeyboardKey.tab, control: true): nextTab, + const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): prevTab, + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + const material.SingleActivator(LogicalKeyboardKey.enter, control: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + const material.SingleActivator(LogicalKeyboardKey.enter, meta: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + const material.SingleActivator(LogicalKeyboardKey.numpadEnter, control: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + const material.SingleActivator(LogicalKeyboardKey.numpadEnter, meta: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + const material.SingleActivator(LogicalKeyboardKey.keyR, control: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + const material.SingleActivator(LogicalKeyboardKey.keyR, meta: true): () { + if (!_activeSession.running) unawaited(execute(_activeSession)); + }, + }, + child: material.Focus( + autofocus: true, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + SqlQueryTabBar( + sessions: _sessions, + selectedIndex: _activeSessionIndex, + onSelect: (index) => setState(() => _activeSessionIndex = index), + onAdd: addNewTab, + onClose: _sessions.length > 1 + ? (index) => unawaited(closeTab(index)) + : null, + ), + material.Expanded( + child: material.IndexedStack( + index: _activeSessionIndex, + children: [ + for (final session in _sessions) + _paneCache.putIfAbsent( + session.id, + () => _buildSessionPane(context, session), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + material.Widget _buildSessionPane( + material.BuildContext context, + SqlQueryTabSession session, + ) { + paneBuildCount++; + final theme = Theme.of(context); + final accent = context.workbench.accent; + + return VerticalSplitPane( + key: material.ValueKey(session.id), + fraction: session.topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + if (widget.headerBadge != null) + widget.headerBadge! + else + const Text('Query').semiBold().small(), + if (effectiveDatabase.isNotEmpty) + Text('DB: $effectiveDatabase').muted().small(), + if (widget.delegate.supportsTransactions) + Text(_txLabel()).muted().small(), + OutlineButton( + size: ButtonSize.small, + onPressed: widget.connectionRow.id != null && !session.running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: effectiveDatabase, + sqlController: session.controller, + onOpenInNewTab: (sql) => addNewTab(initialSql: sql), + ); + } + : null, + leading: material.Icon( + material.Icons.history_rounded, + size: 16, + color: accent, + ), + child: const Text('History'), + ), + OutlineButton( + onPressed: session.running ? null : () => execute(session), + leading: session.running + ? QueryaSpinner( + size: QueryaSpinnerSize.sm, + color: accent, + ) + : material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + color: accent, + ), + child: const Text('Execute (F5)'), + ), + if (widget.extraToolbarTrailing?.call(context, session) case final extra?) + extra, + ], + ), + if (widget.supportsAutocommit || widget.supportsStmtTimeout || widget.delegate.supportsTransactions) ...[ + const Gap(8), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + if (widget.supportsAutocommit) ...[ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Autocommit').small(), + const Gap(6), + material.Switch( + value: _autocommit, + onChanged: session.running + ? null + : (v) { + invalidateAllPanes(); + setState(() => _autocommit = v); + widget.onAutocommitChanged?.call(v); + }, + ), + ], + ), + ], + if (widget.supportsStmtTimeout) ...[ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Stmt timeout').small(), + const Gap(6), + SqlStatementTimeoutDropdown( + value: _queryTimeoutSeconds, + onChanged: _onStmtTimeoutChanged, + enabled: !session.running, + ), + const Gap(4), + IconButton.ghost( + onPressed: session.running ? null : () => showPreferencesDialog(context), + icon: material.Icon( + material.Icons.settings_rounded, + size: 20, + color: accent, + ), + ), + ], + ), + ], + if (widget.delegate.supportsTransactions) ...[ + OutlineButton( + onPressed: session.running ? null : () => runTxCommand('BEGIN'), + child: const Text('Begin'), + ), + OutlineButton( + onPressed: session.running ? null : () => runTxCommand('COMMIT'), + child: const Text('Commit'), + ), + OutlineButton( + onPressed: session.running ? null : () => runTxCommand('ROLLBACK'), + child: const Text('Rollback'), + ), + ], + ], + ), + ], + ], + ), + ), + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: session.controller, + fontSize: _editorFontSize, + ), + ), + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + constraints: const material.BoxConstraints(minHeight: 44), + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: session.columns, + rows: session.rows, + errorMessage: session.error, + isLoading: session.running, + affectedRows: session.affectedRows, + statusLine: session.statusLine, + stagingBuffer: session.stagingBuffer, + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => applyStagedChanges(session), + isSaving: session.savingChanges, + errorAction: widget.errorActionBuilder?.call(context, session), + ), + ), + ], + ), + ); + } + + String _txLabel() { + if (_txOpen == null) return 'Transaction: —'; + return _txOpen! ? 'Transaction: open' : 'Transaction: none'; + } +} diff --git a/lib/features/workspace/sql_execution_delegate.dart b/lib/features/workspace/sql_execution_delegate.dart new file mode 100644 index 00000000..07142a58 --- /dev/null +++ b/lib/features/workspace/sql_execution_delegate.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; + +/// Strategy/delegate contract for executing queries, transactions and DML mutations +/// across different DBMS drivers in [GenericSqlWorkspace]. +abstract class SqlExecutionDelegate { + /// Executes a query statement and returns columns, rows, affected count, and status details. + Future executeQuery( + String sql, { + int? limit, + Duration? timeout, + }); + + /// Explains the SQL query plan if supported by the DBMS. + Future explainQuery(String sql); + + /// Interrupts or cancels active query execution. + Future cancelQuery(); + + /// Whether the database supports explicit multi-statement transactions. + bool get supportsTransactions; + + /// Runs an explicit transaction statement such as `BEGIN`, `COMMIT`, `ROLLBACK`. + Future runTransactionCommand(String command, {Duration? timeout}) => + Future.value(); + + /// Refreshes and returns the open transaction state (`true` = in open tx, `false` = no tx, `null` = unknown). + Future checkTransactionOpen() => Future.value(null); + + /// Resolves the schema (primary keys, column types, metadata) for a targeted table if applicable. + Future resolveTableSchema( + String userSql, + List columns, + ) => + Future.value(SqlResultGridSchema.none); + + /// Applies staged DML mutations in a single atomic transaction. + Future applyStagedMutations({ + required TableMutationPlan plan, + Duration? timeout, + }) => + Future.error(UnsupportedError('DML mutations are not supported')); + + /// Releases resources (leases, connections, listeners) held by the delegate. + void dispose() {} +} + +/// Result returned by [SqlExecutionDelegate.executeQuery]. +@immutable +class SqlExecutionResult { + const SqlExecutionResult({ + this.columns = const [], + this.rows = const [], + this.affectedRows, + this.statusMessage, + this.elapsed, + this.isTruncated = false, + }); + + final List columns; + final List> rows; + final int? affectedRows; + final String? statusMessage; + final Duration? elapsed; + final bool isTruncated; +} diff --git a/lib/features/workspace/workspace.dart b/lib/features/workspace/workspace.dart index 916c0779..56d0bc3f 100644 --- a/lib/features/workspace/workspace.dart +++ b/lib/features/workspace/workspace.dart @@ -6,6 +6,7 @@ export 'data_grid_staging_toolbar.dart'; export 'data_grid_value_panel.dart'; export 'destructive_query_dialog.dart'; export 'dml_preview_dialog.dart'; +export 'generic_sql_workspace.dart'; export 'grid_cell_editor.dart'; export 'grid_cell_popover_inspector.dart'; export 'grid_data_type_validator.dart'; @@ -15,9 +16,12 @@ export 'grid_selection_calc_engine.dart'; export 'query_editor_tab.dart'; export 'result_grid_view.dart'; export 'results_tab.dart'; +export 'save_error_description.dart'; export 'sql_editor_chrome.dart'; +export 'sql_execution_delegate.dart'; export 'sql_query_history_dialog.dart'; export 'sql_query_tab_bar.dart'; export 'sql_query_tab_session.dart'; +export 'sql_result_grid_schema.dart'; export 'table_view_staging.dart'; export 'xml_html_formatter.dart';