From e1031b47673cd174d6d8540f32c2366983901792 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 16 Oct 2026 23:46:05 +0300 Subject: [PATCH 1/3] fix(settings): no wrong-state flashes, kept-alive panes, MCP section polish - Preferences: settings load in parallel; visited panes stay mounted with their own scroll position and are not rebuilt while hidden. - Data Grid: remove 'Confirm DML staging applications', which wrote the SQL destructive-confirmation setting and was read by nothing else. - About: the real app version instead of a hardcoded 0.4.17; the portable-mode future is created once. - MCP: draw nothing that depends on stored state until it loads; list MongoDB connections; product names in type badges; querya-mcp path on its own line; copy buttons and the access token in separate groups; recent calls as rows with a status icon, divider and time. - Bench: Preferences scenarios (open, each category, reopen, scroll, typing, toggles). --- benchmark/app_perf_bench.dart | 69 +++++ .../settings/preferences_about_section.dart | 33 +- lib/features/settings/preferences_dialog.dart | 179 ++++++----- .../settings/preferences_mcp_section.dart | 290 +++++++++++++----- .../preferences_master_detail_test.dart | 47 +++ .../preferences_mcp_section_test.dart | 74 ++++- 6 files changed, 523 insertions(+), 169 deletions(-) diff --git a/benchmark/app_perf_bench.dart b/benchmark/app_perf_bench.dart index 1dc5abab..840de99f 100644 --- a/benchmark/app_perf_bench.dart +++ b/benchmark/app_perf_bench.dart @@ -31,6 +31,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/erd/erd_view.dart'; +import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:vm_service/vm_service.dart' as vms; @@ -108,6 +109,12 @@ Future _runAll() async { await _wait(1500); await _scenario('idle_workspace', () => _wait(1500)); + await _preferencesScenarios(); + if (only.isNotEmpty && + only.split(',').every((id) => id.trim().startsWith('prefs_'))) { + return; // nothing after this point was asked for + } + await _scenario('sidebar_toggle', () async { for (var i = 0; i < 8; i++) { (_findState((s) => s.runtimeType.toString() == '_MainContentSplitState') @@ -268,6 +275,68 @@ Future _runAll() async { dynamic _workspace() => _findState((s) => s.runtimeType.toString() == 'GenericSqlWorkspaceState')!; +/// Preferences over the workspace: the dialog sits on a full-window frosted +/// backdrop, so these show what one repaint inside it costs. +Future _preferencesScenarios() async { + Element dialog() => _find((e) => + e.widget.runtimeType.toString() == '_PreferencesDialogContent')!; + + await _scenario('prefs_open', () async { + showPreferencesDialog(_find((e) => e.widget is ConnectionsPanel)!); + await _waitFor(() => _findText('General Settings') != null, 'Preferences'); + await _wait(600); + }); + await _scenario('prefs_idle', () => _wait(1500)); + await _scenario('prefs_hover', () => _hoverOver(dialog(), 3000)); + // One scenario per category: switching to it builds its pane from scratch. + for (final label in const [ + 'Appearance', 'SQL & Editor', 'Data Grid', 'Extensions', + 'MCP Server', 'Shortcuts', 'About & Storage', 'General', + ]) { + final id = 'prefs_cat_${label.split(' ').first.toLowerCase()}'; + await _scenario(id, () => _tap(_byText(label))); + } + await _scenario('prefs_reopen', () async { + await _tap(_byText('Close')); + await _wait(500); + showPreferencesDialog(_find((e) => e.widget is ConnectionsPanel)!); + await _waitFor(() => _findText('General Settings') != null, 'Preferences'); + await _wait(600); + }); + await _tap(_byText('Shortcuts')); + await _scenario('prefs_scroll', () => _scrollLoop( + dialog, Axis.vertical, 10, 3000, minExtent: 100)); + await _scenario('prefs_typing', () async { + final search = _findState((s) => + s is EditableTextState && + s.widget.controller.runtimeType == TextEditingController) as EditableTextState; + var text = ''; + for (final ch in 'mcp server token'.split('')) { + text += ch; + search.updateEditingValue(TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + )); + await _wait(90); + } + search.updateEditingValue(TextEditingValue.empty); + await _wait(300); + }); + await _tap(_byText('MCP Server')); + await _scenario('prefs_toggle', () async { + final share = _find((e) => '${e.widget.key}'.contains('mcp_share_')); + if (share == null) { + stdout.writeln('BENCH skip: no shareable connection'); + return; + } + for (var i = 0; i < 6; i++) { + await _tap(share); + } + }); + await _tap(_byText('Close')); + await _wait(600); +} + /// Puts [sql] into the active query tab, runs it and waits for the run to end. Future _runInActiveTab(String sql) async { final ws = _workspace(); diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index 75eb42d2..db425c59 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/platform/open_directory.dart'; import 'package:querya_desktop/core/storage/app_data_root.dart'; @@ -8,13 +9,28 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; -class PreferencesAboutSection extends StatelessWidget { +class PreferencesAboutSection extends material.StatefulWidget { const PreferencesAboutSection({super.key}); - static const String appVersion = '0.4.17'; static const String githubRepoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; + @override + material.State createState() => + _PreferencesAboutSectionState(); +} + +class _PreferencesAboutSectionState + extends material.State { + // Created once: a future made in build() would restart on every rebuild of + // the dialog (every keystroke in its search) and make these rows flicker. + late final Future _version = PackageInfo.fromPlatform() + .then((i) => i.version) + .catchError((Object _) => ''); + late final Future _portable = AppDataRoot.isPortableMode(); + + static const githubRepoUrl = PreferencesAboutSection.githubRepoUrl; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -68,9 +84,14 @@ class PreferencesAboutSection extends StatelessWidget { .large() .foreground(), const material.SizedBox(height: 2), - const Text('Version $appVersion (Desktop Build)') - .muted() - .small(), + FutureBuilder( + future: _version, + builder: (context, snapshot) => Text( + snapshot.data?.isNotEmpty == true + ? 'Version ${snapshot.data} (Desktop Build)' + : 'Desktop Build', + ).muted().small(), + ), const material.SizedBox(height: 4), const Text('Fast, native multi-database management studio.') .muted() @@ -91,7 +112,7 @@ class PreferencesAboutSection extends StatelessWidget { const material.SizedBox(height: 12), FutureBuilder( - future: AppDataRoot.isPortableMode(), + future: _portable, builder: (context, snapshot) { final isPortable = snapshot.data ?? false; if (!isPortable) return const SizedBox.shrink(); diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 4ce70256..9f7c92d7 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -1,4 +1,4 @@ -import 'dart:async' show unawaited; +import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; @@ -50,6 +50,16 @@ class _PreferencesDialogContent extends material.StatefulWidget { class PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { late PreferencesCategory _selectedCategory; + + /// Panes opened at least once. They stay mounted (offstage when not + /// selected), so going back to one does not reload it or flash its empty + /// state, and each keeps its own scroll position. + final _visited = {}; + + /// The last widget built for each visited pane. A pane that is not shown + /// reuses it, so a rebuild of the dialog (a category tap, a keystroke in + /// the search) does not rebuild every hidden pane too. + final _paneWidgets = {}; final _searchController = material.TextEditingController(); final _searchFocusNode = material.FocusNode(); String _searchQuery = ''; @@ -90,19 +100,26 @@ class PreferencesDialogContentState setState(() => _searchQuery = query.trim()); } + /// Reads every value in parallel: one round trip to the settings database + /// instead of nine in a row, so the panes show real values sooner. Future _load() async { + final s = AppSettings.instance; try { - final startup = await AppSettings.instance.getCheckForUpdatesOnStartup(); - final channel = await AppSettings.instance.getUpdateChannel(); - final destructive = - await AppSettings.instance.getConfirmDestructiveOperations(); - final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); - final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); - final sqlite = - await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); - final rows = await AppSettings.instance.getSqlResultMaxRows(); - final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); - final font = await AppSettings.instance.getSqlEditorFontSize(); + final (startup, channel, destructive) = await ( + s.getCheckForUpdatesOnStartup(), + s.getUpdateChannel(), + s.getConfirmDestructiveOperations(), + ).wait; + final (pg, my, sqlite) = await ( + s.getPostgresSqlStmtTimeoutSeconds(), + s.getMysqlSqlStmtTimeoutSeconds(), + s.getSqliteSqlStmtTimeoutSeconds(), + ).wait; + final (rows, hist, font) = await ( + s.getSqlResultMaxRows(), + s.getSqlHistoryMaxEntries(), + s.getSqlEditorFontSize(), + ).wait; if (!mounted) return; setState(() { _checkUpdatesOnStartup = startup; @@ -210,8 +227,7 @@ class PreferencesDialogContentState ); count += matching.length; case PreferencesCategory.about: - if ('about version storage directory cache logs sqlite' - .contains(q)) { + if ('about version storage directory cache logs sqlite'.contains(q)) { count++; } } @@ -220,6 +236,7 @@ class PreferencesDialogContentState @override material.Widget build(material.BuildContext context) { + _visited.add(_selectedCategory); final theme = Theme.of(context).colorScheme; final onPopover = theme.popoverForeground; @@ -347,11 +364,11 @@ class PreferencesDialogContentState ), const Gap(8), QueryaIconButton( -icon: const material.Icon(material.Icons.close_rounded), -tooltip: 'Close', -size: QueryaControlSize.sm, -onPressed: () => material.Navigator.of(context).pop(), -), + icon: const material.Icon(material.Icons.close_rounded), + tooltip: 'Close', + size: QueryaControlSize.sm, + onPressed: () => material.Navigator.of(context).pop(), + ), ], ), ), @@ -361,45 +378,44 @@ onPressed: () => material.Navigator.of(context).pop(), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - // Left Category Rail - material.Container( - width: 184, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: theme.border.withValues(alpha: 0.28), - ), - ), - ), - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 8, - vertical: 10, - ), - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.stretch, - children: [ - for (final cat - in PreferencesCategory.values) - _buildCategoryRailTile(cat, theme), - ], - ), - ), + // Left Category Rail + material.Container( + width: 184, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: theme.border.withValues(alpha: 0.28), ), + ), + ), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 10, + ), + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + for (final cat in PreferencesCategory.values) + _buildCategoryRailTile(cat, theme), + ], + ), + ), + ), - // Right Detail Content Area - material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - child: _buildSelectedCategoryPane(theme), - ), - ), + // Right Detail Content Area + material.Expanded( + child: material.Stack( + children: [ + for (final cat in PreferencesCategory.values) + if (_visited.contains(cat)) + _buildPaneHost(cat, theme), ], ), + ), + ], + ), ), // Footer Actions @@ -421,6 +437,27 @@ onPressed: () => material.Navigator.of(context).pop(), ); } + material.Widget _buildPaneHost(PreferencesCategory cat, ColorScheme theme) { + final selected = cat == _selectedCategory; + return material.Offstage( + key: material.ValueKey('preferences_pane_${cat.name}'), + offstage: !selected, + child: material.TickerMode( + enabled: selected, + child: material.SingleChildScrollView( + primary: false, + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: selected + ? _paneWidgets[cat] = _buildPane(cat, theme) + : _paneWidgets[cat] ?? _buildPane(cat, theme), + ), + ), + ); + } + material.Widget _buildCategoryRailTile( PreferencesCategory cat, ColorScheme theme, @@ -498,8 +535,8 @@ onPressed: () => material.Navigator.of(context).pop(), ); } - material.Widget _buildSelectedCategoryPane(ColorScheme theme) { - switch (_selectedCategory) { + material.Widget _buildPane(PreferencesCategory cat, ColorScheme theme) { + switch (cat) { case PreferencesCategory.general: return _buildGeneralSection(theme); case PreferencesCategory.appearance: @@ -529,16 +566,15 @@ onPressed: () => material.Navigator.of(context).pop(), 'Application startup behavior and update release channels.', ), const material.SizedBox(height: 14), - PreferencesSwitchRow( value: _checkUpdatesOnStartup, - title: const Text('Check for updates automatically on startup').small(), + title: + const Text('Check for updates automatically on startup').small(), subtitle: const Text( 'Silently checks GitHub Releases when Querya starts to notify you of new features.', ).muted().xSmall(), onChanged: (v) => unawaited(_setCheckUpdatesOnStartup(v)), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'Release channel', @@ -575,7 +611,6 @@ onPressed: () => material.Navigator.of(context).pop(), 'Global statement timeouts, editor formatting, and query history.', ), const material.SizedBox(height: 16), - PreferencesFieldRow( label: 'Editor font size', control: PreferencesDropdownMenu( @@ -593,18 +628,17 @@ onPressed: () => material.Navigator.of(context).pop(), ], ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'PostgreSQL timeout', - hint: 'Statement timeout applied to queries on PostgreSQL connections.', + hint: + 'Statement timeout applied to queries on PostgreSQL connections.', control: SqlStatementTimeoutDropdown( value: _pgTimeout, expandToParent: true, onChanged: (v) => unawaited(_setPg(v)), ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'MySQL timeout', @@ -616,7 +650,6 @@ onPressed: () => material.Navigator.of(context).pop(), onChanged: (v) => unawaited(_setMysql(v)), ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'SQLite timeout', @@ -628,11 +661,11 @@ onPressed: () => material.Navigator.of(context).pop(), onChanged: (v) => unawaited(_setSqlite(v)), ), ), - const material.SizedBox(height: 16), PreferencesFieldRow( label: 'Query history limit', - hint: 'Per connection and database. Older queries are trimmed automatically.', + hint: + 'Per connection and database. Older queries are trimmed automatically.', control: PreferencesDropdownMenu( value: _historyMax, onSelected: (v) { @@ -647,7 +680,6 @@ onPressed: () => material.Navigator.of(context).pop(), ], ), ), - const material.SizedBox(height: 16), PreferencesSwitchRow( value: _confirmDestructive, @@ -668,10 +700,9 @@ onPressed: () => material.Navigator.of(context).pop(), const Text('Data Grid & Results').semiBold().small().foreground(), const material.SizedBox(height: 6), const PreferencesHint( - 'Configure virtualization limits, column sizing, and result viewing.', + 'How many rows a query result loads into the grid.', ), const material.SizedBox(height: 16), - PreferencesFieldRow( label: 'Max rows in results', hint: @@ -690,16 +721,6 @@ onPressed: () => material.Navigator.of(context).pop(), ], ), ), - - const material.SizedBox(height: 16), - PreferencesSwitchRow( - value: _confirmDestructive, - title: const Text('Confirm DML staging applications').small(), - subtitle: const Text( - 'Requests confirmation before applying staged row inserts, deletes, or cell updates.', - ).muted().xSmall(), - onChanged: (v) => unawaited(_setConfirmDestructive(v)), - ), ], ); } diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 65ff81e6..71f557ff 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -39,14 +39,18 @@ class PreferencesMcpSection extends material.StatefulWidget { PreferencesMcpSectionState(); } -class PreferencesMcpSectionState - extends material.State { +class PreferencesMcpSectionState extends material.State { McpServerController get _controller => widget.controller ?? McpServerController.instance; McpAccessSettings get _access => widget.access ?? McpAccessStore.instance; bool _enabled = false; bool _busy = false; + + /// False until the first load ends: nothing that depends on the stored state + /// (the switch, the connection list, the log) is drawn before that, so the + /// pane never shows "off" / "no connections" and then jumps to the truth. + bool _loaded = false; List _connections = const []; Set _shared = {}; List _activity = const []; @@ -77,17 +81,28 @@ class PreferencesMcpSectionState () => LocalDb.instance.getConnections())(); final shared = await _access.readableIds(); if (!mounted) return; + List activity = const []; + try { + activity = await (widget.loadActivity ?? + () => LocalDb.instance.listMcpActivity(limit: 50))(); + } catch (_) {} + if (!mounted) return; setState(() { _enabled = enabled; _connections = [ for (final c in all) - if (c.id != null && McpQueryService.dialectOf(c.type) != null) c, + if (c.id != null && _mcpReadable(c.type)) c, ]; _shared = shared; + _activity = activity; + _loaded = true; }); - await _loadActivity(); } + /// The connection types an MCP client can read: the SQL tools and MongoDB. + static bool _mcpReadable(String type) => + McpQueryService.dialectOf(type) != null || type == 'mongodb'; + Future _loadActivity() async { try { final list = await (widget.loadActivity ?? @@ -129,7 +144,8 @@ class PreferencesMcpSectionState if (!mounted) return; showAppToast( context: context, - message: '${kind.label} config copied. Paste it into ${kind.configLocation}.', + message: + '${kind.label} config copied. Paste it into ${kind.configLocation}.', variant: AppToastVariant.success, ); } @@ -161,28 +177,44 @@ class PreferencesMcpSectionState 'refused, and no host, user or password is ever sent to the model.', ), const material.SizedBox(height: 14), - PreferencesSwitchRow( - key: const material.ValueKey('mcp_enabled'), - value: _enabled, - enabled: !_busy, - title: const Text('Enable MCP server').small(), - subtitle: material.ValueListenableBuilder( - valueListenable: _controller.status, - builder: (context, s, _) => - Text(_statusText(s)).muted().xSmall(), + if (!_loaded) + const material.Padding( + padding: material.EdgeInsets.symmetric(vertical: 24), + child: material.Center( + child: QueryaSpinner(size: QueryaSpinnerSize.md), + ), + ) + else ...[ + PreferencesSwitchRow( + key: const material.ValueKey('mcp_enabled'), + value: _enabled, + enabled: !_busy, + title: const Text('Enable MCP server').small(), + subtitle: material.ValueListenableBuilder( + valueListenable: _controller.status, + builder: (context, s, _) => Text(_statusText(s)).muted().xSmall(), + ), + onChanged: (v) => unawaited(_setEnabled(v)), ), - onChanged: (v) => unawaited(_setEnabled(v)), - ), - const material.SizedBox(height: 18), - _connectionsBlock(), - const material.SizedBox(height: 18), - _clientsBlock(), - const material.SizedBox(height: 18), - _activityBlock(), + const material.SizedBox(height: 18), + _connectionsBlock(), + const material.SizedBox(height: 18), + _clientsBlock(), + const material.SizedBox(height: 18), + _activityBlock(), + ], ], ); } + static String _typeLabel(String type) => switch (type) { + 'postgresql' => 'PostgreSQL', + 'mysql' => 'MySQL', + 'sqlite' => 'SQLite', + 'mongodb' => 'MongoDB', + _ => type, + }; + String _statusText(McpServerStatus s) { if (s.error != null) return 'Not running: ${s.error}'; if (!s.running) return 'Stopped. AI clients cannot connect.'; @@ -198,14 +230,15 @@ class PreferencesMcpSectionState const material.SizedBox(height: 4), const PreferencesHint( 'Only switched-on connections are visible to MCP clients. ' - 'PostgreSQL, MySQL and SQLite.', + 'PostgreSQL, MySQL, SQLite and MongoDB.', ), const material.SizedBox(height: 8), if (_connections.isEmpty) const QueryaEmptyState( compact: true, - title: 'No SQL connections', - description: 'Add a PostgreSQL, MySQL or SQLite connection first.', + title: 'No connections to share', + description: + 'Add a PostgreSQL, MySQL, SQLite or MongoDB connection first.', ) else for (final c in _connections) @@ -216,7 +249,7 @@ class PreferencesMcpSectionState children: [ material.Flexible(child: Text(c.name).small()), const material.SizedBox(width: 8), - QueryaBadge(label: c.type), + QueryaBadge(label: _typeLabel(c.type)), if (c.environment == ConnectionEnvironment.production) ...[ const material.SizedBox(width: 6), const QueryaBadge.status('PROD', @@ -236,18 +269,42 @@ class PreferencesMcpSectionState } material.Widget _clientsBlock() { + final wb = context.workbench; + final shim = _shimPath; return material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ const Text('Connect a client').semiBold().small().foreground(), const material.SizedBox(height: 4), - PreferencesHint(_shimPath != null - ? 'The client starts querya-mcp, which talks to this running app: ' - '$_shimPath' + PreferencesHint(shim != null + ? 'Copy the config for your client and paste it where it keeps MCP ' + 'servers. The client starts querya-mcp, which talks to this ' + 'running app:' : 'querya-mcp is not bundled with this build. Download it from the ' - 'GitHub release and replace /path/to/querya-mcp in the copied ' - 'config.'), - const material.SizedBox(height: 8), + 'GitHub release, then replace this path in the copied config:'), + const material.SizedBox(height: 6), + // The path on its own line, unbroken, so it can be read whole. + material.Container( + key: const material.ValueKey('mcp_shim_path'), + width: double.infinity, + padding: + const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: material.BoxDecoration( + color: wb.mutedForeground.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Text( + shim ?? '/path/to/${McpClientConfig.executableName}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: wb.mutedForeground, + ), + ), + ), + const material.SizedBox(height: 10), material.Wrap( spacing: 8, runSpacing: 8, @@ -255,19 +312,46 @@ class PreferencesMcpSectionState for (final kind in McpClientKind.values) QueryaButton.secondary( key: material.ValueKey('mcp_copy_${kind.name}'), - label: 'Copy ${kind.label} config', + label: kind == McpClientKind.generic + ? 'Copy stdio config' + : 'Copy ${kind.label} config', + tooltip: kind == McpClientKind.generic + ? 'For any other client that starts MCP servers over stdio' + : null, icon: material.Icons.copy_rounded, size: QueryaControlSize.md, onPressed: () => unawaited(_copy(kind)), ), + ], + ), + const material.SizedBox(height: 18), + // A different kind of action from copying: it cuts connected clients + // off, so it gets its own row with the consequence spelled out. + material.Row( + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Access token').small(), + const material.SizedBox(height: 2), + const PreferencesHint( + 'Regenerating disconnects the connected clients; they ' + 'reconnect on their next start.', + ), + ], + ), + ), + const material.SizedBox(width: 16), QueryaButton.secondary( key: const material.ValueKey('mcp_regenerate'), - label: 'Regenerate token', + label: 'Regenerate', icon: material.Icons.key_rounded, size: QueryaControlSize.md, - tooltip: 'Disconnects connected clients; they reconnect on ' - 'their next start.', - onPressed: _enabled && !_busy ? () => unawaited(_regenerate()) : null, + tooltip: + _enabled ? null : 'Turn the MCP server on to regenerate.', + onPressed: + _enabled && !_busy ? () => unawaited(_regenerate()) : null, ), ], ), @@ -283,15 +367,16 @@ class PreferencesMcpSectionState children: [ const Text('Recent calls').semiBold().small().foreground(), const material.Spacer(), - QueryaButton.secondary( + QueryaButton.ghost( key: const material.ValueKey('mcp_clear_log'), label: 'Clear', - size: QueryaControlSize.md, - onPressed: _activity.isEmpty ? null : () => unawaited(_clearLog()), + size: QueryaControlSize.sm, + onPressed: + _activity.isEmpty ? null : () => unawaited(_clearLog()), ), ], ), - const material.SizedBox(height: 8), + const material.SizedBox(height: 6), if (_activity.isEmpty) const QueryaEmptyState( compact: true, @@ -299,17 +384,21 @@ class PreferencesMcpSectionState description: 'Tool calls from MCP clients appear here.', ) else - for (final e in _activity) _ActivityRow(entry: e), + for (var i = 0; i < _activity.length; i++) + _ActivityRow(entry: _activity[i], first: i == 0), ], ); } } class _ActivityRow extends material.StatelessWidget { - const _ActivityRow({required this.entry}); + const _ActivityRow({required this.entry, this.first = false}); final McpActivityEntry entry; + /// The first row has no divider above it. + final bool first; + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -319,42 +408,95 @@ class _ActivityRow extends material.StatelessWidget { : '${at.hour.toString().padLeft(2, '0')}:' '${at.minute.toString().padLeft(2, '0')}:' '${at.second.toString().padLeft(2, '0')}'; - final result = entry.error != null + final refused = entry.refusalRule != null; + final failed = entry.error != null; + final (icon, iconColor) = refused + ? (material.Icons.block_rounded, wb.warning) + : failed + ? (material.Icons.error_outline_rounded, wb.destructive) + : (material.Icons.check_circle_outline_rounded, wb.success); + final result = failed ? entry.error! : '${entry.rowCount != null ? '${entry.rowCount} row(s) · ' : ''}' '${entry.durationMs} ms'; - return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), - child: material.Column( + + return material.Container( + padding: const material.EdgeInsets.symmetric(vertical: 8), + decoration: first + ? null + : material.BoxDecoration( + border: material.Border( + top: material.BorderSide(color: wb.borderSubtle), + ), + ), + child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - Text([ - time, - entry.client, - entry.tool, - if (entry.connectionName != null) entry.connectionName!, - ].where((s) => s.isNotEmpty).join(' · ')) - .xSmall(), - if (entry.sqlText != null) - Text( - entry.sqlText!, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontFamily: 'monospace', color: wb.mutedForeground), - ).xSmall(), - Text( - result, - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - color: entry.error != null ? wb.destructive : wb.mutedForeground), - ).xSmall(), - // The guard rule that refused the call, when one did. - if (entry.refusalRule != null) - Text('refused by ${entry.refusalRule}') - .xSmall() - .muted(), + material.Padding( + padding: const material.EdgeInsets.only(top: 1), + child: material.Icon(icon, size: 15, color: iconColor), + ), + const material.SizedBox(width: 10), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + Text(entry.tool).small().semiBold(), + // Takes the free space, so the name is cut only when the + // row really is too narrow, and pushes the time right. + material.Expanded( + child: Text( + entry.connectionName == null + ? '' + : ' · ${entry.connectionName}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ).small().muted(), + ), + const material.SizedBox(width: 8), + Text('${entry.client} $time').xSmall().muted(), + ], + ), + if (entry.sqlText != null) ...[ + const material.SizedBox(height: 3), + Text( + entry.sqlText!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontFamily: 'monospace', + color: wb.mutedForeground, + ), + ).xSmall(), + ], + const material.SizedBox(height: 3), + material.Row( + children: [ + material.Flexible( + child: Text( + result, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + color: failed ? wb.destructive : wb.mutedForeground, + ), + ).xSmall(), + ), + // The guard rule that refused the call. + if (refused) ...[ + const material.SizedBox(width: 8), + QueryaBadge.status( + 'refused: ${entry.refusalRule}', + status: QueryaBadgeStatus.warning, + ), + ], + ], + ), + ], + ), + ), ], ), ); diff --git a/test/features/settings/preferences_master_detail_test.dart b/test/features/settings/preferences_master_detail_test.dart index 9390d002..3d12141b 100644 --- a/test/features/settings/preferences_master_detail_test.dart +++ b/test/features/settings/preferences_master_detail_test.dart @@ -126,6 +126,53 @@ void main() { expect(find.text('PostgreSQL timeout'), findsOneWidget); }); + testWidgets('Data Grid has no staging switch bound to the SQL confirmation', + (tester) async { + // It wrote confirm_destructive_operations: turning it off in Data Grid + // silently turned off the DROP / TRUNCATE / DELETE confirmation. + await tester.pumpWidget( + buildTestApp(initialCategory: PreferencesCategory.dataGrid), + ); + await openDialog(tester); + + expect(find.text('Max rows in results'), findsOneWidget); + expect(find.text('Confirm DML staging applications'), findsNothing); + expect(find.byType(material.Switch), findsNothing); + }); + + testWidgets('each pane keeps its own scroll position', (tester) async { + await tester.pumpWidget( + buildTestApp(initialCategory: PreferencesCategory.shortcuts), + ); + await openDialog(tester); + + material.ScrollPosition position(PreferencesCategory cat) => tester + .state(find + .descendant( + of: find + .byKey(material.ValueKey('preferences_pane_${cat.name}')), + matching: find.byType(material.Scrollable), + skipOffstage: false, + ) + .first) + .position; + + final shortcuts = position(PreferencesCategory.shortcuts); + expect(shortcuts.maxScrollExtent, greaterThan(100)); + shortcuts.jumpTo(100); + await tester.pump(); + + await tester.tap(find.text('About & Storage')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(position(PreferencesCategory.about).pixels, 0); + + await tester.tap(find.text('Shortcuts')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(position(PreferencesCategory.shortcuts).pixels, 100); + }); + testWidgets('search bar filters preferences and updates matches', (tester) async { await tester.pumpWidget(buildTestApp()); diff --git a/test/features/settings/preferences_mcp_section_test.dart b/test/features/settings/preferences_mcp_section_test.dart index e07200e2..bfff36cb 100644 --- a/test/features/settings/preferences_mcp_section_test.dart +++ b/test/features/settings/preferences_mcp_section_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart' as material; @@ -12,7 +13,8 @@ import 'package:querya_desktop/features/settings/preferences_mcp_section.dart'; import '../../support/querya_theme_test_shell.dart'; class _Controller extends McpServerController { - _Controller() : super(endpointFile: File('/nonexistent/mcp.json'), version: 't'); + _Controller() + : super(endpointFile: File('/nonexistent/mcp.json'), version: 't'); var enabled = false; final enabledCalls = []; @@ -97,7 +99,8 @@ void main() { loadConnections: () async => [ _row(1, 'postgresql', 'Orders DB', prod: true), _row(2, 'sqlite', 'Local file'), - _row(3, 'mongodb', 'Mongo'), // not SQL: hidden + _row(3, 'mongodb', 'Mongo'), // MongoDB tools: listed too + _row(4, 'redis', 'Cache'), // nothing MCP can read: hidden ], loadActivity: () async => activity, clearActivity: () async => activity = [], @@ -108,17 +111,25 @@ void main() { await tester.pump(); } - testWidgets('lists SQL connections with their shared state', (tester) async { + testWidgets('lists SQL and MongoDB connections with their shared state', + (tester) async { await pump(tester); expect(find.text('Orders DB'), findsOneWidget); expect(find.text('Local file'), findsOneWidget); - expect(find.text('Mongo'), findsNothing); + expect(find.text('Mongo'), findsOneWidget); + expect(find.text('Cache'), findsNothing); expect(find.text('PROD'), findsOneWidget); + // Type badges use the product names, not the stored type ids. + expect(find.text('PostgreSQL'), findsOneWidget); + expect(find.text('SQLite'), findsOneWidget); + expect(find.text('MongoDB'), findsOneWidget); + expect(find.text('postgresql'), findsNothing); - material.Switch sw(int id) => tester.widget(find.descendant( - of: find.byKey(material.ValueKey('mcp_share_$id')), - matching: find.byType(material.Switch))); + material.Switch sw(int id) => + tester.widget(find.descendant( + of: find.byKey(material.ValueKey('mcp_share_$id')), + matching: find.byType(material.Switch))); expect(sw(1).value, isTrue); expect(sw(2).value, isFalse); @@ -149,7 +160,8 @@ void main() { (tester) async { await pump(tester); - await tester.tap(find.byKey(const material.ValueKey('mcp_copy_claudeDesktop'))); + await tester + .tap(find.byKey(const material.ValueKey('mcp_copy_claudeDesktop'))); await tester.pump(); await tester.tap(find.byKey(const material.ValueKey('mcp_copy_vsCode'))); await tester.pump(); @@ -193,7 +205,7 @@ void main() { expect(find.text('1 row(s) · 12 ms'), findsOneWidget); expect(find.text('Data-modifying statements are not allowed over MCP.'), findsOneWidget); - expect(find.text('refused by data_modifying'), findsOneWidget); + expect(find.text('refused: data_modifying'), findsOneWidget); await tester.tap(find.byKey(const material.ValueKey('mcp_clear_log'))); await tester.pump(); @@ -216,6 +228,48 @@ void main() { )); await tester.pump(); await tester.pump(); - expect(find.text('No SQL connections'), findsOneWidget); + expect(find.text('No connections to share'), findsOneWidget); + }); + + testWidgets('nothing that depends on stored state is drawn before it loads', + (tester) async { + final connections = Completer>(); + controller.enabled = true; + await tester.pumpWidget(queryaThemeTestShell( + child: material.SingleChildScrollView( + child: PreferencesMcpSection( + controller: controller, + access: access, + shimPath: '/opt/querya-desktop/querya-mcp', + loadConnections: () => connections.future, + loadActivity: () async => [], + ), + ), + )); + await tester.pump(); + // Still loading: no "off" switch, no "no connections", no "no calls". + expect(find.byKey(const material.ValueKey('mcp_enabled')), findsNothing); + expect(find.text('No connections to share'), findsNothing); + expect(find.text('No calls yet'), findsNothing); + + connections.complete([_row(1, 'postgresql', 'Orders DB')]); + await tester.pump(); + await tester.pump(); + final sw = tester.widget(find.descendant( + of: find.byKey(const material.ValueKey('mcp_enabled')), + matching: find.byType(material.Switch))); + expect(sw.value, isTrue); + expect(find.text('Orders DB'), findsOneWidget); + }); + + testWidgets('the querya-mcp path is shown on its own line', (tester) async { + await pump(tester); + expect( + find.descendant( + of: find.byKey(const material.ValueKey('mcp_shim_path')), + matching: find.text('/opt/querya-desktop/querya-mcp'), + ), + findsOneWidget, + ); }); } From f3d0826a5dacac050d63907591addc9f47909535 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 00:03:46 +0300 Subject: [PATCH 2/3] fix(settings): MCP pane uses the editor's mono font stack; changelog The generic 'monospace' family costs a fontconfig lookup on first use (first MCP visit 10.4 ms build vs 4.3 on dev); the editor's QueryaTypography stack is already resolved when a workspace is open (4.2-5.7 ms). --- CHANGELOG.md | 5 +++++ lib/features/settings/preferences_mcp_section.dart | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 613268d6..475c676f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Demo Marketplace themes were named `${manifest.name}`** — the generated theme.json now carries the real name (the demo catalog is only used with `--dart-define=QUERYA_MARKETPLACE_MOCK=true`). - **In-app update of a package install (AUR, `.deb`, `.rpm`)** — the updater no longer downloads a zip and quits when the app lives in `/opt/querya-desktop` (owned by root): the update dialog says how to update it (`yay -Syu querya-desktop`, `apt`, `dnf`) with a *Copy update command* button, or offers the release page for any other folder the user cannot write. If an in-place replace fails anyway, the app is started again and the reason is logged to `~/.local/state/querya-update.log`, instead of staying closed. +- **Preferences no longer flash a wrong state** — the MCP Server pane showed the server switch off, "No SQL connections" and "No calls yet" for a moment on every visit before the real values jumped in; it now draws them only once loaded. Panes you visited stay as they were (no reload, their own scroll position: Shortcuts no longer opens half-scrolled after another pane), and settings load in one parallel read. +- **Data Grid preferences: removed "Confirm DML staging applications"** — it changed the SQL destructive-query confirmation (DROP / TRUNCATE / DELETE) and controlled nothing else. +- **About shows the real version** instead of a hardcoded 0.4.17. +- **MCP Server pane** — MongoDB connections can be shared (the server already had MongoDB tools), type badges use product names, the `querya-mcp` path is on its own line, copying configs and regenerating the token are separate, and recent calls are rows with a status icon. + ### Planned - Planned 0.5.0 — live Marketplace download and install diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index 71f557ff..31da9fb7 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -8,6 +8,7 @@ import 'package:querya_desktop/core/mcp/mcp_query_service.dart'; import 'package:querya_desktop/core/mcp/mcp_server_controller.dart'; import 'package:querya_desktop/core/security/connection_environment.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_typography.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -298,7 +299,10 @@ class PreferencesMcpSectionState extends material.State { maxLines: 1, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( - fontFamily: 'monospace', + // The editor's font stack: already resolved when a workspace is + // open, while the generic 'monospace' costs a fontconfig lookup. + fontFamily: QueryaTypography.mono, + fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, fontSize: 12, color: wb.mutedForeground, ), @@ -466,7 +470,11 @@ class _ActivityRow extends material.StatelessWidget { maxLines: 2, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( - fontFamily: 'monospace', + // The editor's font stack: already resolved when a workspace is + // open, while the generic 'monospace' costs a fontconfig lookup. + fontFamily: QueryaTypography.mono, + fontFamilyFallback: + QueryaTypography.monoFontFamilyFallback, color: wb.mutedForeground, ), ).xSmall(), From 6427b15c66607e2c9fbcc7234308ce53f45c5e02 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 00:09:48 +0300 Subject: [PATCH 3/3] fix(settings): read preferences sequentially again (sqflite serializes them anyway) --- CHANGELOG.md | 2 +- lib/features/settings/preferences_dialog.dart | 28 ++++++++----------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 475c676f..158a9875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Demo Marketplace themes were named `${manifest.name}`** — the generated theme.json now carries the real name (the demo catalog is only used with `--dart-define=QUERYA_MARKETPLACE_MOCK=true`). - **In-app update of a package install (AUR, `.deb`, `.rpm`)** — the updater no longer downloads a zip and quits when the app lives in `/opt/querya-desktop` (owned by root): the update dialog says how to update it (`yay -Syu querya-desktop`, `apt`, `dnf`) with a *Copy update command* button, or offers the release page for any other folder the user cannot write. If an in-place replace fails anyway, the app is started again and the reason is logged to `~/.local/state/querya-update.log`, instead of staying closed. -- **Preferences no longer flash a wrong state** — the MCP Server pane showed the server switch off, "No SQL connections" and "No calls yet" for a moment on every visit before the real values jumped in; it now draws them only once loaded. Panes you visited stay as they were (no reload, their own scroll position: Shortcuts no longer opens half-scrolled after another pane), and settings load in one parallel read. +- **Preferences no longer flash a wrong state** — the MCP Server pane showed the server switch off, "No SQL connections" and "No calls yet" for a moment on every visit before the real values jumped in; it now draws them only once loaded. Panes you visited stay as they were (no reload, their own scroll position: Shortcuts no longer opens half-scrolled after another pane). - **Data Grid preferences: removed "Confirm DML staging applications"** — it changed the SQL destructive-query confirmation (DROP / TRUNCATE / DELETE) and controlled nothing else. - **About shows the real version** instead of a hardcoded 0.4.17. - **MCP Server pane** — MongoDB connections can be shared (the server already had MongoDB tools), type badges use product names, the `querya-mcp` path is on its own line, copying configs and regenerating the token are separate, and recent calls are rows with a status icon. diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 9f7c92d7..71ac9cd4 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -100,26 +100,20 @@ class PreferencesDialogContentState setState(() => _searchQuery = query.trim()); } - /// Reads every value in parallel: one round trip to the settings database - /// instead of nine in a row, so the panes show real values sooner. + // One after another on purpose: sqflite serializes queries on one database + // anyway, and concurrent ones each start a lock-wait timer. Future _load() async { final s = AppSettings.instance; try { - final (startup, channel, destructive) = await ( - s.getCheckForUpdatesOnStartup(), - s.getUpdateChannel(), - s.getConfirmDestructiveOperations(), - ).wait; - final (pg, my, sqlite) = await ( - s.getPostgresSqlStmtTimeoutSeconds(), - s.getMysqlSqlStmtTimeoutSeconds(), - s.getSqliteSqlStmtTimeoutSeconds(), - ).wait; - final (rows, hist, font) = await ( - s.getSqlResultMaxRows(), - s.getSqlHistoryMaxEntries(), - s.getSqlEditorFontSize(), - ).wait; + final startup = await s.getCheckForUpdatesOnStartup(); + final channel = await s.getUpdateChannel(); + final destructive = await s.getConfirmDestructiveOperations(); + final pg = await s.getPostgresSqlStmtTimeoutSeconds(); + final my = await s.getMysqlSqlStmtTimeoutSeconds(); + final sqlite = await s.getSqliteSqlStmtTimeoutSeconds(); + final rows = await s.getSqlResultMaxRows(); + final hist = await s.getSqlHistoryMaxEntries(); + final font = await s.getSqlEditorFontSize(); if (!mounted) return; setState(() { _checkUpdatesOnStartup = startup;