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
120 changes: 99 additions & 21 deletions lib/core/extensions/extension_command_sync.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import 'package:querya_desktop/core/extensions/models/extension_contributions.da
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart';
import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_exceptions.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/features/extensions/extension_connection_picker_dialog.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// Pushes [ExtensionManifest.contributedCommands] into [QueryaCommandRegistry].
Expand All @@ -30,6 +32,28 @@ class ExtensionCommandSync {
/// Test override for toasts (palette is already closed).
void Function(String message)? toastOverride;

/// Test/DI override for the ambiguous-target connection picker. Returns
/// the chosen connection id, or null if the user cancelled.
Future<int?> Function(
ExtensionManifest manifest,
CommandContribution command,
List<ConnectionRow> candidates,
BuildContext context,
)? pickerOverride;

/// Test/DI override for target resolution — bypasses the live-session
/// lookup (which needs a real driver process to populate) so the
/// none/ambiguous/ready branches of [_invoke] can each be exercised
/// deterministically. Defaults to [ExtensionDriverSession.targetForExtension].
ExtensionCommandTarget Function(
String extensionId, {
int? preferredConnectionId,
})? targetOverride;

/// Test/DI override for the ambiguous-branch candidate id list. Defaults
/// to [ExtensionDriverSession.liveConnectionIdsForExtension].
List<int> Function(String extensionId)? liveConnectionIdsOverride;

bool isEnabled(String extensionId) => !_disabledIds.contains(extensionId);

/// Disables (or re-enables) an installed extension's palette commands.
Expand Down Expand Up @@ -85,7 +109,8 @@ class ExtensionCommandSync {
final preferred =
QueryaCommandHost.maybeOf(context)?.selectedConnectionId;
final session = ExtensionDriverSession.instance;
final target = session.targetForExtension(
final resolveTarget = targetOverride ?? session.targetForExtension;
final target = resolveTarget(
manifest.id,
preferredConnectionId: preferred,
);
Expand All @@ -99,37 +124,87 @@ class ExtensionCommandSync {
);
return;
case ExtensionCommandTargetKind.ambiguous:
_toast(
final connectionId = await _pickAmbiguousTarget(
manifest,
command,
session,
context,
'Select a ${manifest.name} connection to run “${command.title}”.',
variant: AppToastVariant.info,
);
return;
if (connectionId == null) return;
if (!context.mounted) return;
await _executeOn(session, manifest, command, connectionId, context);
case ExtensionCommandTargetKind.ready:
final bridge = session.activeBridgeForExtension(
manifest.id,
preferredConnectionId: target.connectionId,
await _executeOn(
session,
manifest,
command,
target.connectionId!,
context,
);
if (bridge == null) {
_toast(
context,
'Connect a ${manifest.name} session to run “${command.title}”.',
variant: AppToastVariant.info,
);
return;
}
await bridge.sendRequest('commands.execute', {
'id': command.id,
'commandId': command.id,
'connectionId': target.connectionId,
});
}
} catch (error) {
if (!context.mounted) return;
_toast(context, _messageFor(command, error));
}
}

/// Resolves an [ExtensionCommandTargetKind.ambiguous] target: shows a
/// connection picker over the live sessions for [manifest.id] and returns
/// the chosen connection id, or null if the user cancelled (#892).
Future<int?> _pickAmbiguousTarget(
ExtensionManifest manifest,
CommandContribution command,
ExtensionDriverSession session,
BuildContext context,
) async {
final resolveIds =
liveConnectionIdsOverride ?? session.liveConnectionIdsForExtension;
final ids = resolveIds(manifest.id);
final candidates = [
for (final id in ids) await LocalDb.instance.getConnectionById(id),
].whereType<ConnectionRow>().toList();

if (!context.mounted) return null;
final override = pickerOverride;
if (override != null) {
return override(manifest, command, candidates, context);
}

if (candidates.isEmpty) return null;
return showExtensionConnectionPickerDialog(
context: context,
extensionName: manifest.name,
commandTitle: command.title,
connections: candidates,
);
}

Future<void> _executeOn(
ExtensionDriverSession session,
ExtensionManifest manifest,
CommandContribution command,
int connectionId,
BuildContext context,
) async {
final bridge = session.activeBridgeForExtension(
manifest.id,
preferredConnectionId: connectionId,
);
if (bridge == null) {
_toast(
context,
'Connect a ${manifest.name} session to run “${command.title}”.',
variant: AppToastVariant.info,
);
return;
}
await bridge.sendRequest('commands.execute', {
'id': command.id,
'commandId': command.id,
'connectionId': connectionId,
});
}

void _toast(
BuildContext context,
String message, {
Expand Down Expand Up @@ -167,6 +242,9 @@ class ExtensionCommandSync {
_lastManifests = const [];
invokeOverride = null;
toastOverride = null;
pickerOverride = null;
targetOverride = null;
liveConnectionIdsOverride = null;
QueryaCommandRegistry.instance
.unregisterWhere((command) => command.sourceExtensionId != null);
}
Expand Down
14 changes: 10 additions & 4 deletions lib/core/extensions/extension_driver_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,21 @@ class ExtensionDriverSession {
}) {
return resolveExtensionCommandTarget(
extensionId: extensionId,
liveConnectionIds: [
for (final entry in _bridges.entries)
if (entry.value.isStarted) entry.key,
],
liveConnectionIds: liveConnectionIdsForExtension(extensionId),
extensionIdFor: (id) => _manifests[id]?.id,
preferredConnectionId: preferredConnectionId,
);
}

/// Connection ids with a live, started session for [extensionId] — the
/// candidates a connection picker should offer when [targetForExtension]
/// returns [ExtensionCommandTargetKind.ambiguous] (#892).
List<int> liveConnectionIdsForExtension(String extensionId) => [
for (final entry in _bridges.entries)
if (entry.value.isStarted && _manifests[entry.key]?.id == extensionId)
entry.key,
];

PluginRpcBridge? _startedBridge(int connectionId) {
final bridge = _bridges[connectionId];
if (bridge == null || !bridge.isStarted) return null;
Expand Down
114 changes: 114 additions & 0 deletions lib/features/extensions/extension_connection_picker_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/layout/window_layout.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/core/ui/querya_icons.dart';
import 'package:querya_desktop/features/connections/driver_icon.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// Prompts the user to pick which of several equally-live connections an
/// ambiguous extension command should target (#892), instead of silently
/// running against whichever one happens to be first.
Future<int?> showExtensionConnectionPickerDialog({
required BuildContext context,
required String extensionName,
required String commandTitle,
required List<ConnectionRow> connections,
}) {
return showAppDialog<int?>(
context: context,
builder: (dialogContext) => material.Dialog(
backgroundColor: material.Colors.transparent,
insetPadding: WindowLayout.dialogSymmetricInsets(dialogContext),
child: _ExtensionConnectionPickerDialog(
extensionName: extensionName,
commandTitle: commandTitle,
connections: connections,
),
),
);
}

class _ExtensionConnectionPickerDialog extends material.StatelessWidget {
const _ExtensionConnectionPickerDialog({
required this.extensionName,
required this.commandTitle,
required this.connections,
});

final String extensionName;
final String commandTitle;
final List<ConnectionRow> connections;

@override
material.Widget build(material.BuildContext context) {
return QueryaDialogCard(
constraints: WindowLayout.dialogConstraints(
context,
maxWidth: 420,
minWidth: 320,
maxHeight: 420,
),
child: material.Padding(
padding: const material.EdgeInsets.all(16),
child: material.Column(
mainAxisSize: material.MainAxisSize.min,
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
Text('Select a $extensionName connection').semiBold().large(),
const Gap(6),
Text(
'Multiple $extensionName connections are open. Choose which one '
'should run "$commandTitle".',
).muted().small(),
const Gap(12),
material.Flexible(
child: material.ListView.separated(
shrinkWrap: true,
itemCount: connections.length,
separatorBuilder: (_, __) => const Gap(4),
itemBuilder: (context, index) {
final connection = connections[index];
return material.InkWell(
key: material.ValueKey('extension_picker_${connection.id}'),
borderRadius: material.BorderRadius.circular(8),
onTap: () =>
material.Navigator.of(context).pop(connection.id),
child: material.Padding(
padding: const material.EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
child: material.Row(
children: [
DriverIcon(
size: 18,
fallbackIcon:
QueryaIcons.connectionIcon(connection.type),
assetPath:
QueryaIcons.connectionAsset(connection.type),
),
const Gap(10),
material.Expanded(
child: Text(connection.name).small(),
),
],
),
),
);
},
),
),
const Gap(12),
material.Align(
alignment: material.Alignment.centerRight,
child: OutlineButton(
onPressed: () => material.Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
],
),
),
);
}
}
Loading
Loading