| import 'dart:async'; |
| import 'dart:js_interop'; |
| |
| import 'package:codemirror_dart/codemirror_dart.dart' show LSPCodeAction; |
| import 'package:dartpad_editor/dartpad_editor.dart'; |
| import 'package:jaspr/dom.dart'; |
| import 'package:jaspr/jaspr.dart'; |
| import 'package:web/web.dart' as web; |
| |
| import '../../../utils/styles.dart'; |
| |
| class CodeActionPanel extends StatefulComponent { |
| const CodeActionPanel({required this.codeActionsController, super.key}); |
| |
| final CodeActionsController codeActionsController; |
| |
| @override |
| State<CodeActionPanel> createState() => _CodeActionPanelState(); |
| |
| @css |
| static List<StyleRule> get styles => _CodeActionPanelState.styles; |
| } |
| |
| class _CodeActionPanelState extends State<CodeActionPanel> { |
| final GlobalNodeKey panelKey = GlobalNodeKey(); |
| StreamSubscription<web.MouseEvent>? _clickSubscription; |
| |
| @override |
| void initState() { |
| super.initState(); |
| Timer.run(() { |
| if (!mounted) { |
| return; |
| } |
| _clickSubscription = web.EventStreamProviders.mouseDownEvent.forTarget(web.document).listen((event) { |
| final element = panelKey.currentNode; |
| final target = event.target as web.Node?; |
| if (element != null && target != null) { |
| if (!element.contains(target)) { |
| component.codeActionsController.hideCodeActionPanel(); |
| } |
| } |
| }); |
| }); |
| } |
| |
| @override |
| void dispose() { |
| _clickSubscription?.cancel(); |
| super.dispose(); |
| } |
| |
| Map<String, List<LSPCodeAction>> _groupActions(List<LSPCodeAction> actions) { |
| final Map<String, List<LSPCodeAction>> groups = { |
| 'Quick Fix': [], |
| 'Refactor': [], |
| 'Inline': [], |
| 'More Actions': [], |
| }; |
| |
| for (final action in actions) { |
| final kind = action.kind?.toDart; |
| if (kind == null) { |
| groups['More Actions']!.add(action); |
| } else if (kind.startsWith('quickfix')) { |
| groups['Quick Fix']!.add(action); |
| } else if (kind.startsWith('refactor.inline')) { |
| groups['Inline']!.add(action); |
| } else if (kind.startsWith('refactor')) { |
| groups['Refactor']!.add(action); |
| } else { |
| groups['More Actions']!.add(action); |
| } |
| } |
| |
| return groups; |
| } |
| |
| @override |
| Component build(BuildContext context) { |
| final actions = component.codeActionsController.codeActions; |
| final left = component.codeActionsController.panelLeft; |
| final top = component.codeActionsController.panelTop; |
| |
| final List<Component> children = []; |
| if (actions == null) { |
| children.add( |
| const div(classes: 'code-action-empty-item', [ |
| .text('Loading code actions...'), |
| ]), |
| ); |
| } else if (actions.isEmpty) { |
| children.add( |
| const div(classes: 'code-action-empty-item', [ |
| .text('No code actions available'), |
| ]), |
| ); |
| } else { |
| final grouped = _groupActions(actions); |
| bool isFirstGroup = true; |
| |
| for (final entry in grouped.entries) { |
| final groupName = entry.key; |
| final groupActions = entry.value; |
| |
| if (groupActions.isNotEmpty) { |
| if (!isFirstGroup) { |
| children.add(const div(classes: 'code-action-divider', [])); |
| } |
| isFirstGroup = false; |
| |
| children.add( |
| div(classes: 'code-action-group-header', [ |
| .text(groupName), |
| ]), |
| ); |
| |
| children.add( |
| div(classes: 'code-action-group-list', [ |
| for (final action in groupActions) |
| button( |
| classes: 'code-action-btn', |
| type: .button, |
| onClick: () => component.codeActionsController.applyCodeAction(action), |
| [.text(action.title.toDart)], |
| ), |
| ]), |
| ); |
| } |
| } |
| } |
| |
| return div( |
| key: panelKey, |
| classes: 'code-action-floating-panel', |
| styles: Styles( |
| raw: { |
| 'left': '${left}px', |
| 'top': '${top}px', |
| }, |
| ), |
| children, |
| ); |
| } |
| |
| static List<StyleRule> get styles => [ |
| css('.code-action-floating-panel').styles( |
| display: .flex, |
| position: const .fixed(), |
| zIndex: const ZIndex(100), |
| minWidth: 180.px, |
| maxWidth: 320.px, |
| border: .all(color: const .rgba(255, 255, 255, 0.08), width: 1.px), |
| radius: .circular(8.px), |
| shadow: BoxShadow( |
| offsetX: .zero, |
| offsetY: 8.px, |
| blur: 24.px, |
| color: const .rgba(0, 0, 0, 0.5), |
| ), |
| flexDirection: .column, |
| color: colorOnSurface, |
| fontFamily: const .list([FontFamily('Inter'), FontFamilies.sansSerif]), |
| fontSize: 12.px, |
| raw: { |
| 'background-color': 'rgba(30, 30, 30, 0.9)', |
| 'backdrop-filter': 'blur(10px)', |
| '-webkit-backdrop-filter': 'blur(10px)', |
| }, |
| ), |
| css('.code-action-group-header').styles( |
| padding: .only(top: 8.px, right: 12.px, bottom: 4.px, left: 12.px), |
| color: colorOnSurfaceVariant, |
| fontSize: 10.px, |
| fontWeight: .w700, |
| raw: { |
| 'text-transform': 'uppercase', |
| 'letter-spacing': '0.5px', |
| }, |
| ), |
| css('.code-action-group-list').styles( |
| display: .flex, |
| flexDirection: .column, |
| ), |
| css('.code-action-btn').styles( |
| padding: .symmetric(vertical: 6.px, horizontal: 12.px), |
| border: .none, |
| cursor: .pointer, |
| transition: Transition('background-color', duration: 100.ms), |
| color: colorOnSurface, |
| textAlign: .left, |
| fontSize: 12.px, |
| backgroundColor: Colors.transparent, |
| ), |
| css('.code-action-btn:hover').styles( |
| backgroundColor: const .rgba(255, 255, 255, 0.08), |
| ), |
| css('.code-action-divider').styles( |
| height: 1.px, |
| margin: .symmetric(vertical: 4.px), |
| backgroundColor: const .rgba(255, 255, 255, 0.08), |
| ), |
| css('.code-action-empty-item').styles( |
| padding: .symmetric(vertical: 12.px, horizontal: 16.px), |
| color: colorOnSurfaceVariant, |
| textAlign: .center, |
| ), |
| ]; |
| } |