diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e691251..b418fe2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.7-a] - 2026-06-22 + +### Added + +- **Connection Invalidate/Reconnect** — added Invalidate/Reconnect action in the application menu and window title bar. +- **Connection Read-only Mode** — added Read-only mode for PostgreSQL and SQLite database connections to prevent write operations (displays a lock icon in the workspace). +- **Connection Lifecycle Management** — added Connect and Disconnect menu items and corresponding title bar controls. + +### Documentation + +- Added comprehensive setup guides for SQLite, Read-only connection mode, and connection lifecycle controls in the English user guide and Obsidian Russian notes. + ## [0.4.7] - 2026-06-21 Local extension discovery and manifest foundation release. Git tag **`0.4.7`**. diff --git a/docs/user-guide.md b/docs/user-guide.md index 78cc25ee..12997878 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -4,9 +4,18 @@ 1. Start the app. 2. Create a connection: **Connection → New Database Connection** (or right-click **Servers** in the tree). -3. Pick **PostgreSQL**, **MySQL**, **Redis**, or **MongoDB** and fill in host, port, and credentials. +3. Pick **PostgreSQL**, **MySQL**, **SQLite**, **Redis**, or **MongoDB**. For SQLite, select the database file; for other databases, fill in host, port, and credentials. + - **Read-only mode**: You can toggle the **Read-only** option to prevent write operations (a lock icon will appear in the workspace). 4. Saved connections appear in the left tree. +## Connection Actions + +The application menu and window title bar provide actions for managing database connections: +- **Connect**: Connect to the selected database connection. +- **Invalidate/Reconnect**: Refresh and re-establish the connection to the database. +- **Disconnect**: Safely close the active session. +- **Read-only**: Toggle read-only mode for the current session. + ## Where data is stored - **Connection list and settings**: local SQLite (`querya.db` under the app support directory). @@ -33,6 +42,6 @@ Preferences (except secrets) live in the same local SQLite file as connection me ## Supported capabilities -High-level feature depth varies by database type. PostgreSQL and MySQL include rich object trees and SQL workspaces; Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. +High-level feature depth varies by database type. PostgreSQL, MySQL, and SQLite include rich object trees and SQL workspaces (with SQLite utilizing local `.db` files); Redis and MongoDB focus on data exploration and commands suitable for day-to-day development. For troubleshooting build/run issues, see the main [README.md](../README.md). diff --git a/lib/core/actions/sql_editor_actions.dart b/lib/core/actions/sql_editor_actions.dart new file mode 100644 index 00000000..f7ef4cfe --- /dev/null +++ b/lib/core/actions/sql_editor_actions.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +class NewSqlIntent extends Intent { + const NewSqlIntent(); +} + +class OpenSqlIntent extends Intent { + const OpenSqlIntent(); +} + +class SaveSqlIntent extends Intent { + const SaveSqlIntent(); +} diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 07ff8442..eebba457 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -379,6 +379,7 @@ class LocalDb { Future close() async { await _db?.close(); _db = null; + _cachedDbPath = null; } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index cc65b630..40bbfa6e 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -67,6 +67,8 @@ import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/app/app_shutdown.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; @@ -219,6 +221,7 @@ class ConnectionsPanelState extends State { List _connections = []; Map _folderIdByName = {}; final Set _expandedFolders = {}; + final Set _expandedConnections = {}; /// Ignores stale [setState] when multiple [_loadData] runs overlap (e.g. tests). int _loadDataGeneration = 0; @@ -312,6 +315,68 @@ class ConnectionsPanelState extends State { await _loadData(); } + void connect(int connectionId) { + setState(() { + _expandedConnections.add(connectionId); + }); + } + + @visibleForTesting + bool isConnectionExpanded(int id) => _expandedConnections.contains(id); + + Future disconnect(ConnectionRow conn) async { + final id = conn.id!; + setState(() { + _expandedConnections.remove(id); + }); + if (conn.type == 'postgresql') { + PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readOnly); + PostgresService.instance.interrupt(conn, database: conn.databaseName ?? 'postgres', mode: PgSessionMode.readWrite); + } else if (conn.type == 'mysql') { + MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); + MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); + } else if (conn.type == 'sqlite') { + SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readOnly); + SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readWrite); + } else if (conn.type == 'redis') { + final redisConn = RedisService.instance.getConnection(id); + if (redisConn != null) { + await RedisService.instance.disconnect(redisConn); + } + } else if (conn.type == 'mongodb') { + await MongoService.instance.disconnectByConnectionId(id); + } + } + + Future disconnectAll() async { + setState(() { + _expandedConnections.clear(); + }); + await disconnectAllExternalServices(); + await SqliteService.instance.disconnectAll(); + } + + Future disconnectOthers(ConnectionRow keepConn) async { + final keepId = keepConn.id!; + setState(() { + _expandedConnections.clear(); + _expandedConnections.add(keepId); + }); + for (final conn in _connections) { + if (conn.id == keepId) continue; + await disconnect(conn); + } + } + + Future reconnect(ConnectionRow conn) async { + final id = conn.id!; + await disconnect(conn); + await Future.delayed(const Duration(milliseconds: 50)); + if (mounted) { + connect(id); + } + } + /// Icon for a connection type (matches New Connection dialog). material.IconData _iconForType(String type) { return switch (type) { @@ -338,6 +403,17 @@ class ConnectionsPanelState extends State { Widget _buildConnectionTile(ConnectionRow conn) { final isSelected = widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; + final isExpanded = _expandedConnections.contains(conn.id); + void handleExpandedChanged(bool expanded) { + setState(() { + if (expanded) { + _expandedConnections.add(conn.id!); + } else { + _expandedConnections.remove(conn.id!); + } + }); + } + if (conn.type == 'postgresql') { return _PostgresConnectionTile( connection: conn, @@ -348,6 +424,8 @@ class ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'mysql') { return _MysqlConnectionTile( @@ -359,6 +437,8 @@ class ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'redis') { return _RedisConnectionTile( @@ -369,6 +449,8 @@ class ConnectionsPanelState extends State { onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'mongodb') { return _MongoConnectionTile( @@ -379,6 +461,8 @@ class ConnectionsPanelState extends State { onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } else if (conn.type == 'sqlite') { return _SqliteConnectionTile( @@ -390,6 +474,8 @@ class ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, ); } return _ConnectionTile( diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 29ac0343..cc1c5d41 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -11,6 +11,8 @@ class _MongoConnectionTile extends StatefulWidget { required this.onRemove, this.onTap, this.onDatabaseTap, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -20,24 +22,47 @@ class _MongoConnectionTile extends StatefulWidget { final VoidCallback onRemove; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_MongoConnectionTile> createState() => _MongoConnectionTileState(); } class _MongoConnectionTileState extends State<_MongoConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_MongoConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 10c67383..2e7d34eb 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -12,6 +12,8 @@ class _MysqlConnectionTile extends StatefulWidget { this.onTap, this.onMysqlObjectSelected, this.onMysqlOpenSqlWorkspace, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -27,24 +29,47 @@ class _MysqlConnectionTile extends StatefulWidget { MysqlObjectKind kind, )? onMysqlObjectSelected; final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_MysqlConnectionTile> createState() => _MysqlConnectionTileState(); } class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_MysqlConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 925849a8..9bc2a359 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -12,6 +12,8 @@ class _PostgresConnectionTile extends StatefulWidget { this.onTap, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -28,6 +30,8 @@ class _PostgresConnectionTile extends StatefulWidget { PostgresObjectKind kind, )? onPostgresObjectSelected; final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_PostgresConnectionTile> createState() => @@ -35,18 +39,39 @@ class _PostgresConnectionTile extends StatefulWidget { } class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_PostgresConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index efee18af..cc6d687c 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -11,6 +11,8 @@ class _RedisConnectionTile extends StatefulWidget { required this.onRemove, this.onTap, this.onDatabaseTap, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -20,25 +22,48 @@ class _RedisConnectionTile extends StatefulWidget { final VoidCallback onRemove; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_RedisConnectionTile> createState() => _RedisConnectionTileState(); } class _RedisConnectionTileState extends State<_RedisConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; // All 16 databases (db0–db15) with key counts List<({int index, int keys})> _databases = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _databases.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadDatabases(); } } + @override + void didUpdateWidget(_RedisConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_databases.isEmpty && !_loading) { + _loadDatabases(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _databases = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadDatabases() async { if (!mounted) return; setState(() { diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index d02cd014..b403fb6b 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -15,6 +15,8 @@ class _SqliteConnectionTile extends StatefulWidget { this.onTap, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, + this.isExpanded = false, + this.onExpandedChanged, }); final ConnectionRow connection; @@ -29,25 +31,49 @@ class _SqliteConnectionTile extends StatefulWidget { SqliteObjectKind kind, )? onSqliteObjectSelected; final void Function(ConnectionRow connection)? onSqliteOpenSqlWorkspace; + final bool isExpanded; + final ValueChanged? onExpandedChanged; @override State<_SqliteConnectionTile> createState() => _SqliteConnectionTileState(); } class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { - bool _expanded = false; + bool get _expanded => widget.isExpanded; bool _loading = false; String? _error; List _tables = []; List _views = []; - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { + @override + void initState() { + super.initState(); + if (widget.isExpanded) { _loadTables(); } } + @override + void didUpdateWidget(_SqliteConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_tables.isEmpty && _views.isEmpty && !_loading) { + _loadTables(); + } + } else if (!widget.isExpanded && oldWidget.isExpanded) { + setState(() { + _tables = []; + _views = []; + _loading = false; + _error = null; + }); + } + } + + void _toggle() { + widget.onExpandedChanged?.call(!widget.isExpanded); + } + Future _loadTables() async { if (!mounted) return; setState(() { diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index fab3619c..2e5b0d1c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -149,8 +149,45 @@ class _MainScreenState extends State { width: 1, child: Column( children: [ - QueryaWindowTitleBar( - onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + ValueListenableBuilder( + valueListenable: _workspace, + builder: (context, workspace, _) { + return QueryaWindowTitleBar( + onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + onReadOnlyChanged: () { + _workspace.value = _workspace.value.toggleReadOnly(); + }, + onConnect: () { + final active = workspace.activeConnection; + if (active != null && active.id != null) { + _connectionsPanelKey.currentState?.connect(active.id!); + } + }, + onReconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState?.reconnect(active); + } + }, + onDisconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState?.disconnect(active); + } + }, + onDisconnectAll: () { + _connectionsPanelKey.currentState?.disconnectAll(); + }, + onDisconnectOthers: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState?.disconnectOthers(active); + } + }, + ); + }, ), Divider(height: 1, color: wb.borderSubtle.withValues(alpha: 0.22)), Expanded( @@ -303,6 +340,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, selectedSqliteObject: ws.selectedSqliteObject, sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, + isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, ); }, diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index d11f74fa..a973d4a8 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -19,6 +19,7 @@ class MainScreenWorkspaceState { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow? activeConnection; @@ -53,9 +54,27 @@ class MainScreenWorkspaceState { SqliteObjectKind kind })? selectedSqliteObject; final int sqliteSqlTabRequestToken; + final bool isReadOnly; static const empty = MainScreenWorkspaceState(); + MainScreenWorkspaceState toggleReadOnly() { + return MainScreenWorkspaceState( + activeConnection: activeConnection, + activeRedisDb: activeRedisDb, + activeMongoDB: activeMongoDB, + selectedPostgresObject: selectedPostgresObject, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: postgresSqlEditorContext, + postgresSqlEditorContextToken: postgresSqlEditorContextToken, + selectedMysqlObject: selectedMysqlObject, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: selectedSqliteObject, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: !isReadOnly, + ); + } + MainScreenWorkspaceState selectConnection(ConnectionRow connection) { return MainScreenWorkspaceState( activeConnection: connection, @@ -69,6 +88,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: false, ); } @@ -96,6 +116,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -121,6 +142,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -144,6 +166,7 @@ class MainScreenWorkspaceState { kind: kind, ), sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -160,6 +183,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -177,6 +201,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -235,6 +260,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -251,6 +277,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken + 1, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + isReadOnly: isReadOnly, ); } @@ -267,6 +294,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken + 1, + isReadOnly: isReadOnly, ); } @@ -284,7 +312,8 @@ class MainScreenWorkspaceState { _mysqlEquals(selectedMysqlObject, other.selectedMysqlObject) && mysqlSqlTabRequestToken == other.mysqlSqlTabRequestToken && _sqliteEquals(selectedSqliteObject, other.selectedSqliteObject) && - sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken; + sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken && + isReadOnly == other.isReadOnly; } @override @@ -325,6 +354,7 @@ class MainScreenWorkspaceState { selectedSqliteObject!.kind, ), sqliteSqlTabRequestToken, + isReadOnly, ); } diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 1d957b88..bba9a8e3 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -1,9 +1,11 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material show BuildContext, Container, Icon, Icons, MainAxisSize, Widget; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Custom bitsdojo title bar styled from [QueryaThemeScope] workbench tokens. @@ -11,9 +13,25 @@ class QueryaWindowTitleBar extends StatelessWidget { const QueryaWindowTitleBar({ super.key, required this.onNewDatabaseConnection, + this.activeConnection, + this.onConnect, + this.onReconnect, + this.onDisconnect, + this.onDisconnectAll, + this.onDisconnectOthers, + this.isReadOnly = false, + this.onReadOnlyChanged, }); final Future Function() onNewDatabaseConnection; + final ConnectionRow? activeConnection; + final VoidCallback? onConnect; + final VoidCallback? onReconnect; + final VoidCallback? onDisconnect; + final VoidCallback? onDisconnectAll; + final VoidCallback? onDisconnectOthers; + final bool isReadOnly; + final VoidCallback? onReadOnlyChanged; @visibleForTesting static Color titleBarBackground(BuildContext context) => @@ -76,15 +94,33 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( - onPressed: (_) {}, child: const Text('New')), - MenuButton( - onPressed: (_) {}, + onPressed: (ctx) { + Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? ctx, + const NewSqlIntent(), + ); + }, + child: const Text('New')), + MenuButton( + onPressed: (ctx) { + Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? ctx, + const OpenSqlIntent(), + ); + }, child: const Text('Open...')), MenuButton( - onPressed: (_) {}, child: const Text('Save')), + onPressed: (ctx) { + Actions.maybeInvoke( + FocusManager.instance.primaryFocus?.context ?? ctx, + const SaveSqlIntent(), + ); + }, + child: const Text('Save')), const MenuDivider(), MenuButton( - onPressed: (_) {}, child: const Text('Exit')), + onPressed: (_) => appWindow.close(), + child: const Text('Exit')), ], child: const Text('File'), ), @@ -128,39 +164,48 @@ class QueryaWindowTitleBar extends StatelessWidget { ), const MenuDivider(), MenuButton( - enabled: false, + enabled: activeConnection != null, leading: const material.Icon( material.Icons.power_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onConnect?.call(), child: const Text('Connect'), ), MenuButton( + enabled: activeConnection != null, leading: const material.Icon( material.Icons.refresh_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onReconnect?.call(), child: const Text('Invalidate/Reconnect'), ), MenuButton( + enabled: activeConnection != null, leading: const material.Icon( material.Icons.power_off_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onDisconnect?.call(), child: const Text('Disconnect'), ), MenuButton( - onPressed: (_) {}, + onPressed: (_) => onDisconnectAll?.call(), child: const Text('Disconnect All')), MenuButton( - onPressed: (_) {}, + enabled: activeConnection != null, + onPressed: (_) => onDisconnectOthers?.call(), child: const Text('Disconnect Others')), const MenuDivider(), MenuButton( + enabled: activeConnection != null, leading: const material.Icon( material.Icons.lock_outline_rounded, size: 18), - onPressed: (_) {}, + trailing: isReadOnly + ? const material.Icon( + material.Icons.check_rounded, + size: 16) + : null, + onPressed: (_) => onReadOnlyChanged?.call(), child: const Text('Read-only'), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 9e5acd11..80d89b72 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -60,11 +60,13 @@ class WorkspacePanel extends StatefulWidget { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.isReadOnly = false, this.onRequestNewConnection, }); /// Currently selected connection from the sidebar. final ConnectionRow? activeConnection; + final bool isReadOnly; /// When set, the user selected a specific Redis database in the sidebar tree. /// null = show stats, non-null = show data explorer for that db. @@ -159,6 +161,7 @@ class _WorkspacePanelState extends State { postgresSqlEditorContextToken: widget.postgresSqlEditorContextToken, sqlTabRequestToken: widget.postgresSqlTabRequestToken, + isReadOnly: widget.isReadOnly, ) : buildPostgresObjectWorkspace( connection: activeConn, @@ -172,6 +175,7 @@ class _WorkspacePanelState extends State { key: ValueKey('mysql_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + isReadOnly: widget.isReadOnly, ) : MysqlTableView( key: ValueKey( @@ -216,6 +220,7 @@ class _WorkspacePanelState extends State { key: ValueKey('sqlite_home_${activeConn.id}'), connectionRow: activeConn, sqlTabRequestToken: widget.sqliteSqlTabRequestToken, + isReadOnly: widget.isReadOnly, ) : SqliteTableView( key: ValueKey( diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index e2778fa2..42c0aac1 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; 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/database/mysql_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -22,9 +25,11 @@ class MysqlSqlWorkspace extends material.StatefulWidget { const MysqlSqlWorkspace({ super.key, required this.connectionRow, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; @override material.State createState() => _MysqlSqlWorkspaceState(); @@ -63,6 +68,15 @@ class _MysqlSqlWorkspaceState extends material.State { }); } + @override + void didUpdateWidget(covariant MysqlSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly) { + _lease?.release(); + _lease = null; + } + } + Future _loadWorkspaceSettings() async { final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); @@ -91,7 +105,7 @@ class _MysqlSqlWorkspaceState extends material.State { final lease = await MysqlService.instance.acquire( widget.connectionRow, database: _poolDatabaseKey(), - mode: MysqlSessionMode.readWrite, + mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -113,7 +127,7 @@ class _MysqlSqlWorkspaceState extends material.State { MysqlService.instance.interrupt( widget.connectionRow, database: _poolDatabaseKey(), - mode: MysqlSessionMode.readWrite, + mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, ); } _lease?.release(); @@ -237,78 +251,135 @@ 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; + _sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + } catch (_) {} + } + + Future _saveSqlFile() async { + try { + final name = 'query_${DateTime.now().toIso8601String().replaceAll(':', '-')}.sql'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL', extensions: ['sql']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) return; + await File(path).writeAsString(_sqlController.text); + } catch (_) {} + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } - }, + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + _sqlController.clear(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_saveSqlFile()); + return null; + }, + ), }, - child: material.Focus( - autofocus: true, - child: VerticalSplitPane( - fraction: _topFraction, - maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _MysqlSqlToolbar( - onExecute: _running ? null : _execute, - running: _running, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: widget.connectionRow.databaseName, - sqlController: _sqlController, - ); - } - : null, - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, + child: material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) { + unawaited(_execute()); + } + }, + }, + child: material.Focus( + autofocus: true, + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - ], - ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + ], + ), + 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(), ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 81f9f8ad..9005ad9a 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -15,9 +15,11 @@ class MysqlWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; /// Parent increments to switch to the SQL tab (e.g. context menu on connection). final int sqlTabRequestToken; @@ -74,6 +76,14 @@ class _MysqlWorkspaceHomeState extends material.State { child: material.Row( children: [ const Text('MySQL').semiBold().small(), + if (widget.isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ], const Spacer(), ...List.generate(2, (i) { final labels = ['Server', 'SQL']; @@ -120,6 +130,7 @@ class _MysqlWorkspaceHomeState extends material.State { MysqlSqlWorkspace( key: ValueKey('mysql_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, ), ], ), diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index d72b79a9..22bc209f 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,7 +1,10 @@ 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:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; @@ -34,9 +37,11 @@ class PostgresSqlWorkspace extends material.StatefulWidget { this.transactionOpenNotifier, this.postgresSqlEditorContext, this.postgresSqlEditorContextToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; /// Updated when transaction state changes (for tab-switch warnings). final material.ValueNotifier? transactionOpenNotifier; @@ -110,6 +115,9 @@ class _PostgresSqlWorkspaceState extends material.State { if (oldWidget.connectionRow.id != widget.connectionRow.id) { _lastAppliedSqlContextToken = -1; } + if (oldWidget.isReadOnly != widget.isReadOnly) { + _dropLease(); + } _syncPostgresSqlTreeContext(); } @@ -175,7 +183,7 @@ class _PostgresSqlWorkspaceState extends material.State { final lease = await PostgresService.instance.acquire( widget.connectionRow, database: db, - mode: PgSessionMode.readWrite, + mode: widget.isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -256,7 +264,7 @@ class _PostgresSqlWorkspaceState extends material.State { PostgresService.instance.interrupt( widget.connectionRow, database: _interruptDatabase ?? _effectiveSessionDatabase(), - mode: PgSessionMode.readWrite, + mode: widget.isReadOnly ? PgSessionMode.readOnly : PgSessionMode.readWrite, ); } _dropLease(); @@ -377,84 +385,141 @@ class _PostgresSqlWorkspaceState extends material.State { return v.toString(); } + 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; + _sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + } catch (_) {} + } + + Future _saveSqlFile() async { + try { + final name = 'query_${DateTime.now().toIso8601String().replaceAll(':', '-')}.sql'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL', extensions: ['sql']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) return; + await File(path).writeAsString(_sqlController.text); + } catch (_) {} + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) _execute(); - }, + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + _sqlController.clear(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_saveSqlFile()); + return null; + }, + ), }, - child: material.Focus( - autofocus: true, - child: VerticalSplitPane( - fraction: _topFraction, - maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - sessionDatabase: _effectiveSessionDatabase(), - onExecute: _running ? null : _execute, - running: _running, - autocommit: _autocommit, - onAutocommitChanged: (v) => setState(() => _autocommit = v), - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: _effectiveSessionDatabase(), - sqlController: _sqlController, - ); - } - : null, - txOpen: _txOpen, - onBegin: _running ? null : () => _runTxCommand('BEGIN'), - onCommit: _running ? null : () => _runTxCommand('COMMIT'), - onRollback: - _running ? null : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, + child: material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) _execute(); + }, + }, + child: material.Focus( + autofocus: true, + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + sessionDatabase: _effectiveSessionDatabase(), + onExecute: _running ? null : _execute, + running: _running, + autocommit: _autocommit, + onAutocommitChanged: (v) => setState(() => _autocommit = v), + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: _effectiveSessionDatabase(), + sqlController: _sqlController, + ); + } + : null, + txOpen: _txOpen, + onBegin: _running ? null : () => _runTxCommand('BEGIN'), + onCommit: _running ? null : () => _runTxCommand('COMMIT'), + onRollback: + _running ? null : () => _runTxCommand('ROLLBACK'), ), - ), - ], - ), - bottom: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + ], + ), + 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(), ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index e2cea5b7..bcf1b99f 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -18,9 +18,11 @@ class PostgresWorkspaceHome extends material.StatefulWidget { this.postgresSqlEditorContext, this.postgresSqlEditorContextToken = 0, this.sqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; /// Set when opening SQL from the tree (e.g. "Open in SQL") to seed session DB + template. final ({ @@ -120,6 +122,14 @@ class _PostgresWorkspaceHomeState child: material.Row( children: [ const Text('PostgreSQL').semiBold().small(), + if (widget.isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ], const Spacer(), ...List.generate(2, (i) { final labels = ['Server', 'SQL']; @@ -170,6 +180,7 @@ class _PostgresWorkspaceHomeState postgresSqlEditorContext: widget.postgresSqlEditorContext, postgresSqlEditorContextToken: widget.postgresSqlEditorContextToken, + isReadOnly: widget.isReadOnly, ), ], ), diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index b2449b70..2f34b117 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,6 +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/database/sqlite_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -18,9 +21,11 @@ class SqliteSqlWorkspace extends material.StatefulWidget { const SqliteSqlWorkspace({ super.key, required this.connectionRow, + this.isReadOnly = false, }); final ConnectionRow connectionRow; + final bool isReadOnly; @override material.State createState() => _SqliteSqlWorkspaceState(); @@ -57,6 +62,15 @@ class _SqliteSqlWorkspaceState extends material.State { }); } + @override + void didUpdateWidget(covariant SqliteSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isReadOnly != widget.isReadOnly) { + _lease?.release(); + _lease = null; + } + } + Future _loadWorkspaceSettings() async { final rows = await AppSettings.instance.getSqlResultMaxRows(); final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); @@ -75,7 +89,7 @@ class _SqliteSqlWorkspaceState extends material.State { _lease = null; final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: SqliteSessionMode.readWrite, + mode: widget.isReadOnly ? SqliteSessionMode.readOnly : SqliteSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -182,76 +196,133 @@ class _SqliteSqlWorkspaceState extends material.State { } } + 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; + _sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + } catch (_) {} + } + + Future _saveSqlFile() async { + try { + final name = 'query_${DateTime.now().toIso8601String().replaceAll(':', '-')}.sql'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL', extensions: ['sql']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) return; + await File(path).writeAsString(_sqlController.text); + } catch (_) {} + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } - }, + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (intent) { + _sqlController.clear(); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_openSqlFile()); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (intent) { + unawaited(_saveSqlFile()); + return null; + }, + ), }, - child: material.Focus( - autofocus: true, - child: VerticalSplitPane( - fraction: _topFraction, - maxFraction: 0.85, - top: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqliteSqlToolbar( - onExecute: _running ? null : _execute, - running: _running, - onOpenPreferences: () => showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: widget.connectionRow.databaseName, - sqlController: _sqlController, - ); - } - : null, - ), - const Divider(height: 1), - material.Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, + child: material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) { + unawaited(_execute()); + } + }, + }, + child: material.Focus( + autofocus: true, + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _SqliteSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - ], - ), - bottom: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, + const Divider(height: 1), + material.Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), + ], + ), + 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(), ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - material.Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, + const Divider(height: 1), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 67991e09..94fc438a 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -8,10 +8,12 @@ class SqliteWorkspaceHome extends material.StatefulWidget { super.key, required this.connectionRow, this.sqlTabRequestToken = 0, + this.isReadOnly = false, }); final ConnectionRow connectionRow; final int sqlTabRequestToken; + final bool isReadOnly; @override material.State createState() => _SqliteWorkspaceHomeState(); @@ -33,6 +35,14 @@ class _SqliteWorkspaceHomeState extends material.State { child: material.Row( children: [ const Text('SQLite').semiBold().small(), + if (widget.isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ], const Spacer(), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -50,6 +60,7 @@ class _SqliteWorkspaceHomeState extends material.State { child: SqliteSqlWorkspace( key: ValueKey('sqlite_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + isReadOnly: widget.isReadOnly, ), ), ], diff --git a/pubspec.yaml b/pubspec.yaml index 33f52043..835024d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.7+13 +version: 0.4.7-a diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index 3f68e14b..4a67b096 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -246,4 +246,124 @@ void main() { _expectTextCount('Mongo local', 1); }); }); + + group('ConnectionsPanel state control methods', () { + late Directory stateTempDir; + + setUp(() async { + stateTempDir = await Directory.systemTemp.createTemp('querya_conn_state_test_'); + PathProviderPlatform.instance = _FakePathProvider(stateTempDir.path); + await LocalDb.instance.close(); + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'generic', + name: 'Conn 1', + createdAt: _isoNow(), + ), + ); + await LocalDb.instance.addConnection( + ConnectionRow( + type: 'generic', + name: 'Conn 2', + createdAt: _isoNow(), + ), + ); + await FoldersStorage.instance.reload(); + }); + + tearDown(() async { + await LocalDb.instance.close(); + if (await stateTempDir.exists()) { + await stateTempDir.delete(recursive: true); + } + }); + + testWidgets('connect, disconnect, disconnectAll, and disconnectOthers update state', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.SizedBox.expand( + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + onPostgresOpenSqlWorkspace: (_, {database, schema, name, kind}) {}, + ), + ), + ), + ); + await tester.pump(); + + final panelState = tester.state( + find.byType(ConnectionsPanel), + ); + + await tester.runAsync(() async { + await panelState.reloadConnectionsFromDb(); + }); + await tester.pump(); + + late final List conns; + await tester.runAsync(() async { + conns = await LocalDb.instance.getConnections(); + }); + final conn1 = conns.firstWhere((c) => c.name == 'Conn 1'); + final conn2 = conns.firstWhere((c) => c.name == 'Conn 2'); + final id1 = conn1.id!; + final id2 = conn2.id!; + + // 1. Initial state + expect(panelState.isConnectionExpanded(id1), false); + expect(panelState.isConnectionExpanded(id2), false); + + // 2. Connect + panelState.connect(id1); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), false); + + // 3. Disconnect Others + panelState.connect(id2); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), true); + + await tester.runAsync(() async { + await panelState.disconnectOthers(conn1); + }); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), false); + + // 4. Disconnect + await tester.runAsync(() async { + await panelState.disconnect(conn1); + }); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), false); + + // 5. Disconnect All + panelState.connect(id1); + panelState.connect(id2); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + expect(panelState.isConnectionExpanded(id2), true); + + await tester.runAsync(() async { + await panelState.disconnectAll(); + }); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), false); + expect(panelState.isConnectionExpanded(id2), false); + + // 6. Reconnect + panelState.connect(id1); + await tester.pump(); + expect(panelState.isConnectionExpanded(id1), true); + + await tester.runAsync(() async { + await panelState.reconnect(conn1); + }); + await tester.pump(const Duration(milliseconds: 100)); + expect(panelState.isConnectionExpanded(id1), true); + }); + }); } diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index 4023ef9e..411eac6c 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -166,5 +166,23 @@ void main() { ); expect(a, isNot(c)); }); + + test('read-only state toggle and reset', () { + var state = MainScreenWorkspaceState.empty; + expect(state.isReadOnly, isFalse); + + state = state.toggleReadOnly(); + expect(state.isReadOnly, isTrue); + + state = state.toggleReadOnly(); + expect(state.isReadOnly, isFalse); + + state = state.toggleReadOnly(); + expect(state.isReadOnly, isTrue); + + // Selecting a new connection should reset isReadOnly to false + state = state.selectConnection(mysqlConn); + expect(state.isReadOnly, isFalse); + }); }); }