Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**.
Expand Down
13 changes: 11 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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).
13 changes: 13 additions & 0 deletions lib/core/actions/sql_editor_actions.dart
Original file line number Diff line number Diff line change
@@ -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();
}
1 change: 1 addition & 0 deletions lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ class LocalDb {
Future<void> close() async {
await _db?.close();
_db = null;
_cachedDbPath = null;
}
}

Expand Down
86 changes: 86 additions & 0 deletions lib/features/connections/connections_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -219,6 +221,7 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
List<ConnectionRow> _connections = [];
Map<String, int> _folderIdByName = {};
final Set<String> _expandedFolders = {};
final Set<int> _expandedConnections = {};

/// Ignores stale [setState] when multiple [_loadData] runs overlap (e.g. tests).
int _loadDataGeneration = 0;
Expand Down Expand Up @@ -312,6 +315,68 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
await _loadData();
}

void connect(int connectionId) {
setState(() {
_expandedConnections.add(connectionId);
});
}

@visibleForTesting
bool isConnectionExpanded(int id) => _expandedConnections.contains(id);

Future<void> 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<void> disconnectAll() async {
setState(() {
_expandedConnections.clear();
});
await disconnectAllExternalServices();
await SqliteService.instance.disconnectAll();
}

Future<void> 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<void> reconnect(ConnectionRow conn) async {
final id = conn.id!;
await disconnect(conn);
await Future<void>.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) {
Expand All @@ -338,6 +403,17 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
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,
Expand All @@ -348,6 +424,8 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
onTap: () => widget.onConnectionSelected?.call(conn),
onPostgresObjectSelected: widget.onPostgresObjectSelected,
onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace,
isExpanded: isExpanded,
onExpandedChanged: handleExpandedChanged,
);
} else if (conn.type == 'mysql') {
return _MysqlConnectionTile(
Expand All @@ -359,6 +437,8 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
onTap: () => widget.onConnectionSelected?.call(conn),
onMysqlObjectSelected: widget.onMysqlObjectSelected,
onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace,
isExpanded: isExpanded,
onExpandedChanged: handleExpandedChanged,
);
} else if (conn.type == 'redis') {
return _RedisConnectionTile(
Expand All @@ -369,6 +449,8 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
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(
Expand All @@ -379,6 +461,8 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
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(
Expand All @@ -390,6 +474,8 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
onTap: () => widget.onConnectionSelected?.call(conn),
onSqliteObjectSelected: widget.onSqliteObjectSelected,
onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace,
isExpanded: isExpanded,
onExpandedChanged: handleExpandedChanged,
);
}
return _ConnectionTile(
Expand Down
33 changes: 29 additions & 4 deletions lib/features/connections/connections_panel_mongo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class _MongoConnectionTile extends StatefulWidget {
required this.onRemove,
this.onTap,
this.onDatabaseTap,
this.isExpanded = false,
this.onExpandedChanged,
});

final ConnectionRow connection;
Expand All @@ -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<bool>? 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<String> _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<void> _loadDatabases() async {
if (!mounted) return;
setState(() {
Expand Down
33 changes: 29 additions & 4 deletions lib/features/connections/connections_panel_mysql.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class _MysqlConnectionTile extends StatefulWidget {
this.onTap,
this.onMysqlObjectSelected,
this.onMysqlOpenSqlWorkspace,
this.isExpanded = false,
this.onExpandedChanged,
});

final ConnectionRow connection;
Expand All @@ -27,24 +29,47 @@ class _MysqlConnectionTile extends StatefulWidget {
MysqlObjectKind kind,
)? onMysqlObjectSelected;
final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace;
final bool isExpanded;
final ValueChanged<bool>? 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<String> _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<void> _loadDatabases() async {
if (!mounted) return;
setState(() {
Expand Down
Loading
Loading