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
43 changes: 13 additions & 30 deletions lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -611,38 +611,21 @@ class LocalDb {
await db.insert('folders', {'name': n});
}

/// Removes the folder **and the connections in it** (the foreign key
/// cascades), so their secrets go with them: the cascade does not reach the
/// OS keychain.
/// Removes the folder; its connections stay, without a folder.
///
/// `connections.folder_id` is `ON DELETE CASCADE`, which would delete them
/// with it. They are detached first, so the cascade finds nothing: no schema
/// migration of the connections table is needed.
Future<void> removeFolder(String name) async {
final db = await _open();
final inside = await db.rawQuery(
'SELECT c.id AS id FROM connections c '
'JOIN folders f ON f.id = c.folder_id WHERE f.name = ?',
[name],
);
for (final row in inside) {
final id = _sqliteInt(row['id']);
if (id == null) continue;
_historyInsertCounts.removeWhere((k, _) => k.startsWith('$id::'));
try {
await ConnectionSecretsStore.deleteForConnection(id);
} catch (_) {
// Do not block removing the folder when the OS store fails.
}
}
await db.delete('folders', where: 'name = ?', whereArgs: [name]);
}

/// How many connections [removeFolder] would delete.
Future<int> countConnectionsInFolder(String name) async {
final db = await _open();
final rows = await db.rawQuery(
'SELECT COUNT(*) AS n FROM connections c '
'JOIN folders f ON f.id = c.folder_id WHERE f.name = ?',
[name],
);
return _sqliteInt(rows.first['n']) ?? 0;
await db.transaction((txn) async {
await txn.rawUpdate(
'UPDATE connections SET folder_id = NULL WHERE folder_id IN '
'(SELECT id FROM folders WHERE name = ?)',
[name],
);
await txn.delete('folders', where: 'name = ?', whereArgs: [name]);
});
}

Future<void> clearFolders() async {
Expand Down
18 changes: 1 addition & 17 deletions lib/features/connections/connections_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart';
import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart';
import 'package:querya_desktop/features/mysql/mysql_object_kind.dart';
import 'new_folder_dialog.dart';
import 'remove_folder_dialog.dart';

part 'connections_panel_sidebar.dart';
part 'connections_panel_redis.dart';
Expand Down Expand Up @@ -734,23 +733,8 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
await _loadData();
}

/// Removing a folder deletes the connections in it, so a folder that holds
/// some is confirmed first and their open sessions are closed.
/// Removes the folder; its connections stay in the list without one.
Future<void> _removeFolder(String name) async {
final inside = _connections
.where((c) => c.folderId == _folderIdByName[name] && c.id != null)
.toList();
if (inside.isNotEmpty) {
final ok = await confirmRemoveFolder(
context,
name: name,
connectionCount: inside.length,
);
if (!ok || !mounted) return;
for (final c in inside) {
await _closeSessionsOf(c.id!);
}
}
await FoldersStorage.instance.remove(name);
if (mounted) await _loadData();
}
Expand Down
54 changes: 0 additions & 54 deletions lib/features/connections/remove_folder_dialog.dart

This file was deleted.

53 changes: 30 additions & 23 deletions test/core/storage/local_db_folder_removal_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import 'package:querya_desktop/core/storage/local_db.dart';

import '../../support/local_db_test_support.dart';

/// Removing a folder deletes the connections in it (foreign key cascade). Their
/// secrets are in the OS store, which the cascade does not reach.
/// `connections.folder_id` cascades on delete; removing a folder must not take
/// its connections, or their saved passwords, with it.
void main() {
late Directory dir;

Expand All @@ -23,28 +23,35 @@ void main() {
createdAt: DateTime.utc(2026).toIso8601String(),
);

test('removing a folder deletes its connections and their secrets',
test('removing a folder keeps its connections, now without a folder',
() async {
final db = LocalDb.instance;
await db.addFolder('Doomed');
final folderId = await db.getFolderIdByName('Doomed');
final inside = await db.addConnection(row('Inside', folderId: folderId));
final outside = await db.addConnection(row('Outside'));
await ConnectionSecretsStore.writeForConnection(inside, password: 'in-pw');
await ConnectionSecretsStore.writeForConnection(outside,
password: 'out-pw');

expect(await db.countConnectionsInFolder('Doomed'), 1);
expect(await db.countConnectionsInFolder('Nowhere'), 0);

await db.removeFolder('Doomed');

final names = (await db.getConnections()).map((c) => c.name);
expect(names, contains('Outside'));
expect(names, isNot(contains('Inside')));
expect((await ConnectionSecretsStore.readForConnection(inside)).password,
anyOf(isNull, isEmpty));
expect((await ConnectionSecretsStore.readForConnection(outside)).password,
'out-pw');
await db.addFolder('Team A');
await db.addFolder('Team B');
final a = await db.getFolderIdByName('Team A');
final b = await db.getFolderIdByName('Team B');
final inA = await db.addConnection(row('In A', folderId: a));
final inB = await db.addConnection(row('In B', folderId: b));
final root = await db.addConnection(row('Root'));
await ConnectionSecretsStore.writeForConnection(inA, password: 'a-pw');

await db.removeFolder('Team A');

final byId = {for (final c in await db.getConnections()) c.id: c};
expect(byId.keys, containsAll([inA, inB, root]));
expect(byId[inA]!.folderId, isNull);
expect(byId[inB]!.folderId, b, reason: 'another folder is untouched');
expect(byId[root]!.folderId, isNull);
expect(await db.getFolderIdByName('Team A'), isNull);
expect((await ConnectionSecretsStore.readForConnection(inA)).password,
'a-pw');
});

test('removing an empty or unknown folder is harmless', () async {
final db = LocalDb.instance;
await db.addFolder('Empty');
await db.removeFolder('Empty');
await db.removeFolder('Never existed');
expect(await db.getFolderIdByName('Empty'), isNull);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ void main() {
expect(row, isNotNull);
expect(row!.name, 'E2E Form SQLite');
expect(row.host, path);
expect(inSidebar('E2E Form SQLite'), findsOneWidget);
// The sidebar reloads from the database after the save.
await waitFor(tester, inSidebar('E2E Form SQLite'), 'the new connection');
await app.close(tester);
});

Expand Down
39 changes: 0 additions & 39 deletions test/features/connections/remove_folder_dialog_test.dart

This file was deleted.

Loading