From 69f62d98a374dd825777844c96513798a573de9a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 13 Jul 2026 01:06:31 +0300 Subject: [PATCH 1/2] feat(desktop): implement MySQL and SQLite UI parity (#325) - Add explicit transaction controls (Begin, Commit, Rollback) to MysqlSqlWorkspace - Add procedure/function listing and MysqlRoutineView DDL inspector via SHOW CREATE PROCEDURE/FUNCTION - Add SqliteOverviewTab with database size, page statistics, journal mode, version, and table listing - Add DDL inspector button and dialog to SqliteTableView --- lib/core/database/mysql_connection.dart | 28 ++ lib/core/database/sqlite_connection.dart | 36 ++ .../connections/connections_panel_mysql.dart | 58 ++- lib/features/main_screen/workspace_panel.dart | 46 +- lib/features/mysql/mysql_object_kind.dart | 2 + lib/features/mysql/mysql_routine_view.dart | 191 ++++++++ lib/features/mysql/mysql_sql_workspace.dart | 29 ++ lib/features/sqlite/sqlite_overview_tab.dart | 415 ++++++++++++++++++ lib/features/sqlite/sqlite_table_view.dart | 83 +++- .../sqlite/sqlite_workspace_home.dart | 92 +++- 10 files changed, 940 insertions(+), 40 deletions(-) create mode 100644 lib/features/mysql/mysql_routine_view.dart create mode 100644 lib/features/sqlite/sqlite_overview_tab.dart diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 53c294d1..d1fb0ad6 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -353,6 +353,34 @@ class MysqlConnection { return rs.rows.map((r) => r.colAt(0)!).toList(); } + /// Lists stored procedures in [schema] (database name). + Future> listProcedures({required String schema}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final rs = await execute( + 'SELECT ROUTINE_NAME FROM information_schema.ROUTINES ' + "WHERE ROUTINE_SCHEMA = :schema AND ROUTINE_TYPE = 'PROCEDURE' " + 'ORDER BY ROUTINE_NAME', + {'schema': schema}, + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + /// Lists stored functions in [schema] (database name). + Future> listFunctions({required String schema}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final rs = await execute( + 'SELECT ROUTINE_NAME FROM information_schema.ROUTINES ' + "WHERE ROUTINE_SCHEMA = :schema AND ROUTINE_TYPE = 'FUNCTION' " + 'ORDER BY ROUTINE_NAME', + {'schema': schema}, + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + Future serverVersion() async { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index cf2cefeb..15455a72 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -155,6 +155,42 @@ class SqliteConnection { return rows.map((r) => r['name'] as String).toList(); } + /// Returns the DDL (`sql`) of a table or view from sqlite_master. + Future getObjectDdl(String objectName) async { + final rows = await execute( + "SELECT sql FROM sqlite_master WHERE name = :name", + [objectName], + ); + if (rows.isEmpty) return '-- No definition found for $objectName'; + return (rows.first['sql'] as String?) ?? '-- Empty definition'; + } + + /// Returns database overview info (`page_count`, `page_size`, `journal_mode`, version, etc.). + Future> databaseOverview() async { + final verRows = await execute('SELECT sqlite_version() AS ver'); + final ver = (verRows.isNotEmpty ? verRows.first['ver'] : '') ?? ''; + + final pcRows = await execute('PRAGMA page_count'); + final pc = (pcRows.isNotEmpty ? pcRows.first.values.first : 0) ?? 0; + + final psRows = await execute('PRAGMA page_size'); + final ps = (psRows.isNotEmpty ? psRows.first.values.first : 0) ?? 0; + + final jmRows = await execute('PRAGMA journal_mode'); + final jm = (jmRows.isNotEmpty ? jmRows.first.values.first : '') ?? ''; + + final tblCountRows = await execute("SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); + final tblCount = (tblCountRows.isNotEmpty ? tblCountRows.first['c'] : 0) ?? 0; + + return { + 'version': ver, + 'page_count': pc, + 'page_size': ps, + 'journal_mode': jm, + 'table_count': tblCount, + }; + } + /// Helper to quote SQLite identifiers safely. static String quoteIdentifier(String id) { return '"${id.replaceAll('"', '""')}"'; diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 2e7d34eb..fb88a466 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -371,10 +371,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { bool _loading = false; List _tables = []; List _views = []; + List _procedures = []; + List _functions = []; void _toggle() { setState(() => _expanded = !_expanded); - if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { + if (_expanded && + _tables.isEmpty && + _views.isEmpty && + _procedures.isEmpty && + _functions.isEmpty && + !_loading) { _loadTables(); } } @@ -394,10 +401,16 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { await lease.connection.listTables(schema: widget.databaseName); final views = await lease.connection.listViews(schema: widget.databaseName); + final procs = + await lease.connection.listProcedures(schema: widget.databaseName); + final funcs = + await lease.connection.listFunctions(schema: widget.databaseName); if (!mounted) return; setState(() { _tables = tables; _views = views; + _procedures = procs; + _functions = funcs; _loading = false; }); } catch (e) { @@ -468,7 +481,10 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ], ), ), - if (_tables.isNotEmpty || _views.isNotEmpty) + if (_tables.isNotEmpty || + _views.isNotEmpty || + _procedures.isNotEmpty || + _functions.isNotEmpty) material.Padding( padding: const material.EdgeInsets.only(left: 16), child: material.Column( @@ -512,6 +528,44 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { MysqlObjectKind.view, ), ), + if (_procedures.isNotEmpty) + _MysqlObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + objectKind: MysqlObjectKind.procedure, + onRefresh: _loadTables, + label: 'Procedures', + icon: material.Icons.functions_rounded, + itemIcon: material.Icons.code_rounded, + items: _procedures, + onItemTap: widget.onMysqlObjectSelected == null + ? null + : (name) => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + name, + MysqlObjectKind.procedure, + ), + ), + if (_functions.isNotEmpty) + _MysqlObjectGroup( + connection: widget.connection, + databaseName: widget.databaseName, + objectKind: MysqlObjectKind.function, + onRefresh: _loadTables, + label: 'Functions', + icon: material.Icons.functions_rounded, + itemIcon: material.Icons.code_rounded, + items: _functions, + onItemTap: widget.onMysqlObjectSelected == null + ? null + : (name) => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + name, + MysqlObjectKind.function, + ), + ), ], ), ), diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 89bba87f..fcd22118 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -33,6 +33,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_routine_view.dart'; import 'package:querya_desktop/features/mysql/mysql_table_view.dart'; import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; @@ -183,22 +184,35 @@ class _WorkspacePanelState extends State { break; case 'mysql': final my = widget.selectedMysqlObject; - driverWorkspace = my == null - ? MysqlWorkspaceHome( - key: ValueKey('mysql_home_${activeConn.id}'), - connectionRow: activeConn, - sqlTabRequestToken: widget.mysqlSqlTabRequestToken, - isReadOnly: widget.isReadOnly, - ) - : MysqlTableView( - key: ValueKey( - 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', - ), - connectionRow: activeConn, - database: my.database, - tableName: my.name, - isView: my.kind == MysqlObjectKind.view, - ); + if (my == null) { + driverWorkspace = MysqlWorkspaceHome( + key: ValueKey('mysql_home_${activeConn.id}'), + connectionRow: activeConn, + sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + isReadOnly: widget.isReadOnly, + ); + } else if (my.kind == MysqlObjectKind.procedure || + my.kind == MysqlObjectKind.function) { + driverWorkspace = MysqlRoutineView( + key: ValueKey( + 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: activeConn, + database: my.database, + routineName: my.name, + isFunction: my.kind == MysqlObjectKind.function, + ); + } else { + driverWorkspace = MysqlTableView( + key: ValueKey( + 'mysql_${activeConn.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: activeConn, + database: my.database, + tableName: my.name, + isView: my.kind == MysqlObjectKind.view, + ); + } break; case 'mongodb': final mongoDb = widget.selectedMongoDb; diff --git a/lib/features/mysql/mysql_object_kind.dart b/lib/features/mysql/mysql_object_kind.dart index f7c4cf32..5fc3c0ee 100644 --- a/lib/features/mysql/mysql_object_kind.dart +++ b/lib/features/mysql/mysql_object_kind.dart @@ -2,4 +2,6 @@ enum MysqlObjectKind { table, view, + procedure, + function, } diff --git a/lib/features/mysql/mysql_routine_view.dart b/lib/features/mysql/mysql_routine_view.dart new file mode 100644 index 00000000..bd94c14a --- /dev/null +++ b/lib/features/mysql/mysql_routine_view.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Displays MySQL routine DDL (`SHOW CREATE PROCEDURE` / `SHOW CREATE FUNCTION`). +class MysqlRoutineView extends material.StatefulWidget { + const MysqlRoutineView({ + super.key, + required this.connectionRow, + required this.database, + required this.routineName, + required this.isFunction, + }); + + final ConnectionRow connectionRow; + final String database; + final String routineName; + final bool isFunction; + + @override + material.State createState() => _MysqlRoutineViewState(); +} + +class _MysqlRoutineViewState extends material.State { + MysqlLease? _lease; + bool _loading = true; + String? _error; + String? _ddlText; + + @override + void initState() { + super.initState(); + _loadRoutine(); + } + + @override + void didUpdateWidget(covariant MysqlRoutineView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.routineName != widget.routineName || + oldWidget.isFunction != widget.isFunction) { + _lease?.release(); + _lease = null; + _loadRoutine(); + } + } + + @override + void dispose() { + _lease?.release(); + super.dispose(); + } + + Future _loadRoutine() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _ddlText = null; + }); + + try { + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.readOnly, + ); + _lease = lease; + + final kindCmd = widget.isFunction ? 'FUNCTION' : 'PROCEDURE'; + final rs = await lease.connection + .execute('SHOW CREATE $kindCmd `${widget.routineName}`'); + + if (!mounted) return; + if (rs.rows.isEmpty) { + setState(() { + _error = '$kindCmd `${widget.routineName}` not found.'; + _loading = false; + }); + return; + } + + // SHOW CREATE PROCEDURE/FUNCTION usually returns: + // [Procedure/Function, sql_mode, Create Procedure/Function, ...] + // The DDL is typically column index 2. + var ddl = rs.rows.first.colAt(2); + if (ddl == null || ddl.isEmpty) { + for (var i = 0; i < rs.rows.first.numOfColumns; i++) { + final val = rs.rows.first.colAt(i); + if (val != null && + (val.toUpperCase().contains('CREATE PROCEDURE') || + val.toUpperCase().contains('CREATE DEFINER') || + val.toUpperCase().contains('CREATE FUNCTION'))) { + ddl = val; + break; + } + } + } + + setState(() { + _ddlText = ddl ?? rs.rows.first.colAt(1) ?? '-- No definition returned'; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final kindTitle = widget.isFunction ? 'Function' : 'Procedure'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: material.BoxDecoration( + color: theme.colorScheme.card, + border: material.Border( + bottom: material.BorderSide( + color: theme.colorScheme.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.functions_rounded, + size: 18, + color: theme.colorScheme.primary, + ), + const Gap(8), + material.Expanded( + child: Text('$kindTitle · ${widget.database}.${widget.routineName}') + .semiBold() + .small(), + ), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading ? null : _loadRoutine, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), + ), + ], + ), + ), + if (_loading) + const material.Expanded( + child: material.Center( + child: material.CircularProgressIndicator(), + ), + ) + else if (_error != null) + material.Expanded( + child: material.Center( + child: material.SelectableText( + _error!, + style: material.TextStyle(color: cs.destructive, fontSize: 13), + ), + ), + ) + else + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: material.SelectableText( + _ddlText ?? '', + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 13, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 49878c7a..8f518de0 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -300,6 +300,14 @@ class _MysqlSqlWorkspaceState extends material.State { } catch (_) {} } + Future _runTxCommand(String sql) async { + _sqlController.value = material.TextEditingValue( + text: sql, + selection: material.TextSelection.collapsed(offset: sql.length), + ); + await _execute(); + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); @@ -357,6 +365,9 @@ class _MysqlSqlWorkspaceState extends material.State { ); } : null, + onBegin: _running ? null : () => _runTxCommand('START TRANSACTION;'), + onCommit: _running ? null : () => _runTxCommand('COMMIT;'), + onRollback: _running ? null : () => _runTxCommand('ROLLBACK;'), ), const Divider(height: 1), Expanded( @@ -409,6 +420,9 @@ class _MysqlSqlToolbar extends material.StatelessWidget { required this.onQueryTimeoutChanged, required this.onOpenPreferences, this.onOpenHistory, + required this.onBegin, + required this.onCommit, + required this.onRollback, }); final Future Function()? onExecute; @@ -417,6 +431,9 @@ class _MysqlSqlToolbar extends material.StatelessWidget { final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; final VoidCallback? onOpenHistory; + final VoidCallback? onBegin; + final VoidCallback? onCommit; + final VoidCallback? onRollback; @override material.Widget build(material.BuildContext context) { @@ -490,6 +507,18 @@ class _MysqlSqlToolbar extends material.StatelessWidget { ), ], ), + OutlineButton( + onPressed: onBegin, + child: const Text('Begin'), + ), + OutlineButton( + onPressed: onCommit, + child: const Text('Commit'), + ), + OutlineButton( + onPressed: onRollback, + child: const Text('Rollback'), + ), ], ), ], diff --git a/lib/features/sqlite/sqlite_overview_tab.dart b/lib/features/sqlite/sqlite_overview_tab.dart new file mode 100644 index 00000000..c3822580 --- /dev/null +++ b/lib/features/sqlite/sqlite_overview_tab.dart @@ -0,0 +1,415 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _summaryChipHeight = 88.0; + +/// Overview tab (`Overview / Database Info`) for SQLite workspace home. +class SqliteOverviewTab extends material.StatefulWidget { + const SqliteOverviewTab({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => _SqliteOverviewTabState(); +} + +class _SqliteOverviewTabState extends material.State { + SqliteLease? _lease; + SqliteConnection? get _connection => _lease?.connection; + + Map? _overview; + List _tables = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant SqliteOverviewTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _disconnectCurrent(); + _load(); + } + } + + @override + void dispose() { + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + _lease?.release(); + _lease = null; + } + + Future _load() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _overview = null; + _tables = []; + }); + try { + final lease = await SqliteService.instance.acquire(widget.connectionRow); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + await _fetch(); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetch() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final info = await c.databaseOverview(); + final tbls = await c.listTables(); + if (!mounted) return; + setState(() { + _overview = info; + _tables = tbls; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + static String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + var size = bytes.toDouble(); + var idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx++; + } + return '${size.toStringAsFixed(idx == 0 ? 0 : 2)} ${units[idx]}'; + } + + int _getFileSizeOnDisk() { + try { + final path = widget.connectionRow.host ?? ''; + if (path.isNotEmpty && path != ':memory:') { + final f = File(path); + if (f.existsSync()) { + return f.lengthSync(); + } + } + } catch (_) {} + return 0; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = material.MediaQuery.of(context).size.width; + + if (_loading && _overview == null) { + return material.Center( + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(12), + const Text('Loading database overview...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final info = _overview; + if (info == null) return material.Container(color: cs.background); + + final diskSize = _getFileSizeOnDisk(); + final pageCount = info['page_count'] as int? ?? 0; + final pageSize = info['page_size'] as int? ?? 0; + final calcSize = pageCount * pageSize; + final sizeStr = diskSize > 0 ? _formatBytes(diskSize) : _formatBytes(calcSize); + final versionStr = info['version']?.toString() ?? '—'; + final journalStr = info['journal_mode']?.toString().toUpperCase() ?? '—'; + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, versionStr, sizeStr, journalStr, pageCount, pageSize), + const Gap(24), + _tablesCard(context), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final name = widget.connectionRow.name; + final path = widget.connectionRow.host ?? 'in-memory'; + return material.Row( + children: [ + material.Container( + width: 44, + height: 44, + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(10), + ), + child: material.Icon(material.Icons.storage_rounded, + color: cs.primary, size: 24), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + Text(name).large().semiBold(), + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: material.BoxDecoration( + color: cs.muted, + borderRadius: material.BorderRadius.circular(4), + ), + child: const Text('SQLite').xSmall().muted(), + ), + ], + ), + const Gap(4), + material.Text( + path, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle(fontSize: 13, color: cs.mutedForeground), + ), + ], + ), + ), + OutlineButton( + onPressed: _fetch, + leading: const material.Icon(material.Icons.refresh_rounded, size: 16), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _summaryChips( + material.BuildContext context, + String version, + String size, + String journal, + int pageCount, + int pageSize, + ) { + final cs = Theme.of(context).colorScheme; + + material.Widget chip(String label, String value, material.IconData icon) { + return material.Expanded( + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), + child: material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), + ), + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + return material.Row( + children: [ + chip('SQLite Version', version, material.Icons.info_outline_rounded), + const Gap(12), + chip('Database Size', size, material.Icons.data_usage_rounded), + const Gap(12), + chip('Pages / Page Size', '$pageCount / $pageSize B', material.Icons.find_in_page_rounded), + const Gap(12), + chip('Journal Mode', journal, material.Icons.history_rounded), + ], + ); + } + + material.Widget _tablesCard(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + return material.Container( + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), + ), + padding: const material.EdgeInsets.all(20), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.Icon(material.Icons.table_chart_rounded, + size: 18, color: cs.primary), + const Gap(8), + const Text('Tables').semiBold(), + const Gap(8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: cs.muted, + borderRadius: material.BorderRadius.circular(4), + ), + child: Text('${_tables.length}').xSmall().muted(), + ), + ], + ), + const Gap(16), + if (_tables.isEmpty) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 24), + child: material.Center( + child: Text('No user tables in database.').muted().small(), + ), + ) + else + material.Wrap( + spacing: 8, + runSpacing: 8, + children: _tables.map((t) { + return material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3)), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.grid_on_rounded, + size: 14, color: cs.primary), + const Gap(6), + Text(t).small(), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 955ad042..b5766219 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -190,6 +190,53 @@ class _SqliteTableViewState extends material.State { unawaited(_fetch()); } + Future _showDdlDialog() async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + material.showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const material.Center( + child: material.CircularProgressIndicator(), + ), + ); + try { + final ddl = await conn.getObjectDdl(widget.tableName); + if (!mounted) return; + material.Navigator.of(context).pop(); + await material.showDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: material.Text( + '${widget.isView ? "View" : "Table"} DDL · ${widget.tableName}'), + content: material.SizedBox( + width: 600, + height: 400, + child: material.SingleChildScrollView( + child: material.SelectableText( + ddl, + style: const material.TextStyle( + fontFamily: 'monospace', fontSize: 13), + ), + ), + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('Close'), + ), + ], + ), + ); + } catch (e) { + if (!mounted) return; + material.Navigator.of(context).pop(); + material.ScaffoldMessenger.of(context).showSnackBar( + material.SnackBar(content: material.Text('Failed to fetch DDL: $e')), + ); + } + } + String _paginationLabel() { if (_columnNames.isEmpty && _rows.isEmpty) return ''; final start = _offset + 1; @@ -241,7 +288,7 @@ class _SqliteTableViewState extends material.State { decoration: material.BoxDecoration( border: material.Border( right: material.BorderSide( - color: cs.border.withValues(alpha: 0.15), + color: cs.border.withValues(alpha: 0.22), width: 1, ), ), @@ -249,7 +296,6 @@ class _SqliteTableViewState extends material.State { child: material.Text( name, overflow: material.TextOverflow.ellipsis, - maxLines: 1, style: material.TextStyle( fontSize: 12, fontWeight: material.FontWeight.w600, @@ -260,7 +306,6 @@ class _SqliteTableViewState extends material.State { } material.Widget _dataCell(ColorScheme cs, String value) { - final isNull = value == 'NULL'; return material.Container( width: 150, padding: const material.EdgeInsets.symmetric(horizontal: 10), @@ -273,15 +318,16 @@ class _SqliteTableViewState extends material.State { ), ), ), - child: material.Text( + child: material.SelectableText( value, + maxLines: 1, style: material.TextStyle( fontSize: 12, - color: isNull ? cs.mutedForeground.withValues(alpha: 0.5) : cs.foreground, - fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + fontFamily: 'monospace', + color: value == 'NULL' + ? cs.mutedForeground.withValues(alpha: 0.7) + : cs.foreground, ), - overflow: material.TextOverflow.ellipsis, - maxLines: 1, ), ); } @@ -293,8 +339,19 @@ class _SqliteTableViewState extends material.State { if (_loading && _columnNames.isEmpty) { return material.Container( color: cs.background, - child: const material.Center( - child: material.CircularProgressIndicator(), + child: material.Center( + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(12), + const Text('Loading table data...').muted().small(), + ], + ), ), ); } @@ -384,6 +441,12 @@ class _SqliteTableViewState extends material.State { ), ), const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading ? null : () => unawaited(_showDdlDialog()), + child: const Text('DDL'), + ), + const Gap(6), OutlineButton( onPressed: _loading ? null : () => unawaited(_fetch()), child: const Text('Refresh'), diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 94fc438a..6269baf1 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -1,5 +1,11 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/core/storage/local_db.dart' show ConnectionRow; +import 'package:querya_desktop/features/sqlite/sqlite_overview_tab.dart'; import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -20,6 +26,37 @@ class SqliteWorkspaceHome extends material.StatefulWidget { } class _SqliteWorkspaceHomeState extends material.State { + int _tab = 0; + int _lastAppliedSqlTabToken = 0; + + @override + void initState() { + super.initState(); + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + } + + @override + void didUpdateWidget(covariant SqliteWorkspaceHome oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + return; + } + final t = widget.sqlTabRequestToken; + if (t > _lastAppliedSqlTabToken) { + _lastAppliedSqlTabToken = t; + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _tab = 1); + }); + } + } + + void _selectTab(int i) { + if (i == _tab) return; + setState(() => _tab = i); + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); @@ -44,23 +81,54 @@ class _SqliteWorkspaceHomeState extends material.State { ), ], const Spacer(), - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: material.BoxDecoration( - color: theme.colorScheme.background, - borderRadius: material.BorderRadius.circular(6), - ), - child: const Text('SQL').small().semiBold(), - ), + ...List.generate(2, (i) { + final labels = ['Overview', 'SQL']; + final selected = _tab == i; + return material.Padding( + padding: const material.EdgeInsets.only(left: 6), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () => _selectTab(i), + child: material.AnimatedContainer( + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: selected + ? theme.colorScheme.background + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(6), + ), + child: selected + ? Text(labels[i]).small().semiBold() + : Text(labels[i]).small().muted(), + ), + ), + ), + ); + }), ], ), ), const Divider(height: 1), Expanded( - child: SqliteSqlWorkspace( - key: ValueKey('sqlite_sql_${widget.connectionRow.id}'), - connectionRow: widget.connectionRow, - isReadOnly: widget.isReadOnly, + child: QueryaCrossFadeStack( + index: _tab, + children: [ + SqliteOverviewTab( + key: ValueKey('sqlite_overview_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + SqliteSqlWorkspace( + key: ValueKey('sqlite_sql_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, + ), + ], ), ), ], From 26f1cb2312c836fa76da7a22fac959b369595337 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 13 Jul 2026 01:11:07 +0300 Subject: [PATCH 2/2] fix(sqlite): resolve analyzer info and warning lints (#325) --- lib/features/sqlite/sqlite_overview_tab.dart | 2 +- lib/features/sqlite/sqlite_workspace_home.dart | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/features/sqlite/sqlite_overview_tab.dart b/lib/features/sqlite/sqlite_overview_tab.dart index c3822580..c7f75c7c 100644 --- a/lib/features/sqlite/sqlite_overview_tab.dart +++ b/lib/features/sqlite/sqlite_overview_tab.dart @@ -379,7 +379,7 @@ class _SqliteOverviewTabState extends material.State { material.Padding( padding: const material.EdgeInsets.symmetric(vertical: 24), child: material.Center( - child: Text('No user tables in database.').muted().small(), + child: const Text('No user tables in database.').muted().small(), ), ) else diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 6269baf1..627320b1 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -1,5 +1,3 @@ -import 'dart:async' show unawaited; - import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart';