diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 4ec6a38..db1db6d 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -15,7 +15,6 @@ */ import 'dart:convert'; -import 'dart:math'; import 'package:proxypin/network/channel/host_port.dart'; import 'package:proxypin/network/http/content_type.dart'; @@ -23,6 +22,7 @@ import 'package:proxypin/network/http/websocket.dart'; import 'package:proxypin/network/util/compress.dart'; import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/network/util/process_info.dart'; +import 'package:proxypin/network/util/random.dart'; import 'http_headers.dart'; @@ -68,7 +68,7 @@ abstract class HttpMessage { String? remoteHost; int? remotePort; - String requestId = (DateTime.now().millisecondsSinceEpoch + Random().nextInt(999999)).toRadixString(36); + String requestId = (DateTime.now().millisecondsSinceEpoch).toRadixString(36) + RandomUtil.randomString(8); //请求id int? streamId; // http2 streamId HttpMessage(this.protocolVersion); diff --git a/lib/ui/component/multi_select_controller.dart b/lib/ui/component/multi_select_controller.dart new file mode 100644 index 0000000..dec9f80 --- /dev/null +++ b/lib/ui/component/multi_select_controller.dart @@ -0,0 +1,121 @@ +import 'package:get/get.dart'; +import 'package:proxypin/utils/listenable_list.dart'; + +class MultiSelectController { + final ListenableList selectedIds = ListenableList(); + + String? _anchorId; + RxBool selectionMode = false.obs; + + bool get isSelectionMode => selectionMode.value; + + int get selectedCount => selectedIds.length; + + bool contains(String requestId) => selectedIds.contains(requestId); + + void clear() { + if (selectedIds.isEmpty && !selectionMode.value) { + return; + } + selectedIds.clear(); + _anchorId = null; + selectionMode.value = false; + } + + void enterSelectionMode([String? requestId]) { + selectionMode.value = true; + if (requestId != null) { + selectedIds.add(requestId); + _anchorId = requestId; + } + } + + void selectOnly(String requestId) { + selectionMode.value = true; + selectedIds + ..clear() + ..add(requestId); + _anchorId = requestId; + } + + void toggleSelectionMode([String? requestId]) { + if (selectionMode.value) { + clear(); + } else { + enterSelectionMode(requestId); + } + } + + void toggle(String requestId) { + selectionMode.value = true; + if (selectedIds.contains(requestId)) { + selectedIds.remove(requestId); + } else { + selectedIds.add(requestId); + } + + if (selectedIds.isEmpty) { + clear(); + return; + } + + _anchorId = requestId; + } + + void selectRange(List orderedIds, String requestId) { + final targetIndex = orderedIds.indexOf(requestId); + if (targetIndex < 0) { + return; + } + + final anchorIndex = _anchorId == null ? -1 : orderedIds.indexOf(_anchorId!); + if (anchorIndex < 0) { + selectOnly(requestId); + return; + } + + final start = anchorIndex < targetIndex ? anchorIndex : targetIndex; + final end = anchorIndex > targetIndex ? anchorIndex : targetIndex; + selectionMode.value = true; + selectedIds + ..clear() + ..addAll(orderedIds.sublist(start, end + 1)); + _anchorId = requestId; + } + + void prune(Iterable visibleIds) { + final visibleIdSet = visibleIds.toSet(); + selectedIds.removeWhere((requestId) => !visibleIdSet.contains(requestId)); + + if (selectedIds.isEmpty) { + clear(); + return; + } + + selectionMode.value = true; + if (_anchorId == null || !selectedIds.contains(_anchorId)) { + _anchorId = selectedIds.last; + } + } +} + +class MultiSelectListener extends ListenerListEvent { + final Function(List items) onChange; + + MultiSelectListener(this.onChange); + + @override + void onAdd(T item) => onChange.call([item]); + + @override + void onRemove(T item) => onChange.call([item]); + + @override + void onUpdate(T item) => onChange.call([item]); + + @override + void onBatchRemove(List items) => onChange.call(items); + + @override + void clear(List items) => onChange.call(items); +} diff --git a/lib/ui/component/selection_action_bar.dart b/lib/ui/component/selection_action_bar.dart new file mode 100644 index 0000000..a7bf8b6 --- /dev/null +++ b/lib/ui/component/selection_action_bar.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; +import 'package:proxypin/ui/component/multi_select_controller.dart'; +import 'package:proxypin/utils/listenable_list.dart'; + +class SelectionActionBar extends StatelessWidget { + final MultiSelectController selectionController; + final VoidCallback? onRepeat; + final VoidCallback? onExport; + final VoidCallback? onDelete; + + const SelectionActionBar({super.key, required this.selectionController, this.onRepeat, this.onExport, this.onDelete}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context); + + return SizedBox( + height: 36, + child: Row(children: [ + const SizedBox(width: 8), + _SelectLabel(selectionController: selectionController), + const Spacer(), + if (onRepeat != null) + IconButton(onPressed: onRepeat, tooltip: localizations?.repeat, icon: const Icon(Icons.repeat, size: 18)), + if (onExport != null) + IconButton( + onPressed: onExport, tooltip: localizations?.export, icon: const Icon(Icons.share_outlined, size: 18)), + if (onDelete != null) + IconButton( + onPressed: onDelete, tooltip: localizations?.delete, icon: const Icon(Icons.delete_outline, size: 18)), + IconButton(onPressed: _onCancel, tooltip: localizations?.cancel, icon: const Icon(Icons.close, size: 18)), + ])); + } + + void _onCancel() { + selectionController.clear(); + } +} + +class _SelectLabel extends StatefulWidget { + final MultiSelectController selectionController; + + const _SelectLabel({required this.selectionController}); + + @override + State createState() => _SelectLabelState(); +} + +class _SelectLabelState extends State<_SelectLabel> { + late final OnchangeListEvent _listener; + + @override + void initState() { + super.initState(); + _listener = OnchangeListEvent(_onSelectionChanged); + widget.selectionController.selectedIds.addListener(_listener); + } + + @override + void dispose() { + widget.selectionController.selectedIds.removeListener(_listener); + super.dispose(); + } + + void _onSelectionChanged() { + if (!mounted) { + return; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final selectLabel = AppLocalizations.of(context)?.selectAction; + final selectedCount = widget.selectionController.selectedIds.length; + final label = (selectLabel == null || selectLabel.isEmpty) ? '$selectedCount' : '$selectedCount $selectLabel'; + + return Text(label, style: Theme.of(context).textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis); + } +} + +class ClearSelectionIntent extends Intent { + const ClearSelectionIntent(); +} diff --git a/lib/ui/desktop/request/domians.dart b/lib/ui/desktop/request/domains.dart similarity index 87% rename from lib/ui/desktop/request/domians.dart rename to lib/ui/desktop/request/domains.dart index 78f2ac3..f9f17e2 100644 --- a/lib/ui/desktop/request/domians.dart +++ b/lib/ui/desktop/request/domains.dart @@ -21,16 +21,17 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_context_menu/flutter_desktop_context_menu.dart'; -import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/bin/configuration.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/channel/channel.dart'; import 'package:proxypin/network/channel/channel_context.dart'; -import 'package:proxypin/network/components/host_filter.dart'; import 'package:proxypin/network/channel/host_port.dart'; +import 'package:proxypin/network/components/host_filter.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_client.dart'; +import 'package:proxypin/ui/component/multi_select_controller.dart'; import 'package:proxypin/ui/component/transition.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/content/panel.dart'; @@ -52,6 +53,8 @@ class DomainList extends StatefulWidget { final ListenableList list; final bool shrinkWrap; final Function(List)? onRemove; + final MultiSelectController selectionController; + final RequestSelectionHandlers selectionHandlers; const DomainList( {super.key, @@ -59,7 +62,9 @@ class DomainList extends StatefulWidget { required this.list, this.shrinkWrap = true, required this.panel, - this.onRemove}); + this.onRemove, + required this.selectionController, + required this.selectionHandlers}); @override State createState() { @@ -79,11 +84,14 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM bool changing = false; //是否存在刷新任务 //关键词高亮监听 late VoidCallback highlightListener; + late MultiSelectListener selectionListener; bool sortDesc = true; AppLocalizations get localizations => AppLocalizations.of(context)!; + MultiSelectController get selectionController => widget.selectionController; + void changeState() { if (!changing) { changing = true; @@ -110,10 +118,19 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM }); }; KeywordHighlights.addListener(highlightListener); + + selectionListener = MultiSelectListener((items) { + if (!mounted) { + return; + } + _refreshRequestSelection(items); + }); + selectionController.selectedIds.addListener(selectionListener); } @override - dispose() { + void dispose() { + selectionController.selectedIds.removeListener(selectionListener); KeywordHighlights.removeListener(highlightListener); super.dispose(); } @@ -130,6 +147,7 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM if (searchModel?.isNotEmpty == true) { searchView = searchFilter(searchModel!); list = searchView.values; + selectionController.prune(list.expand((e) => e.body).map((e) => e.request.requestId).toSet()); } else { searchView.clear(); } @@ -161,7 +179,7 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM } ///高亮处理 - highlightHandler() { + void highlightHandler() { //获取所有请求Widget List requests = containerMap.values.map((e) => e.body).expand((element) => element).toList(); for (RequestWidget request in requests) { @@ -171,7 +189,7 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM } ///添加请求 - add(Channel channel, HttpRequest request) { + void add(Channel channel, HttpRequest request) { String? host = request.remoteDomain(); if (host == null) { return; @@ -208,6 +226,8 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM widget.onRemove?.call([req]); changeState(); }, + selectionController: selectionController, + selectionHandlers: widget.selectionHandlers, ); containerMap[host] = domainRequests; } @@ -228,7 +248,7 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM } ///移除域名 - deleteHost(String host) { + void deleteHost(String host) { DomainRequests? domainRequests = containerMap.remove(host); if (domainRequests == null) { return; @@ -258,7 +278,7 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM } } - remove(List list) { + void remove(List list) { for (var request in list) { String? host = request.remoteDomain(); containerMap[host]?._removeRequest(request); @@ -330,6 +350,37 @@ class DomainWidgetState extends State with AutomaticKeepAliveClientM request.changeState(); }); } + + List selectedRequests() { + final selectedIds = selectionController.selectedIds; + if (selectedIds.isEmpty) { + return []; + } + return currentView().where((request) => selectedIds.contains(request.requestId)).toList(); + } + + void selectRange(HttpRequest request) { + final currentIds = currentView().map((item) => item.requestId).toList(); + if (currentIds.isEmpty) { + return; + } + + selectionController.selectRange(currentIds, request.requestId); + } + + void _refreshRequestSelection(List selectedIds) { + var container = containerMap.values; + if (searchModel?.isNotEmpty == true) { + container = searchView.values; + } + for (var domain in container) { + for (var requestWidget in domain.body) { + if (selectedIds.contains(requestWidget.request.requestId)) { + requestWidget.changeState(); + } + } + } + } } ///标题和内容布局 标题是域名 内容是域名下请求 @@ -351,6 +402,8 @@ class DomainRequests extends StatefulWidget { final Function(String host)? onDelete; final Function(String host)? onExportHar; final Function(HttpRequest request)? onRequestRemove; + final RequestSelectionHandlers selectionHandlers; + final MultiSelectController selectionController; DomainRequests(this.domain, {this.selected = false, @@ -358,7 +411,9 @@ class DomainRequests extends StatefulWidget { this.onExportHar, required this.proxyServer, this.onRequestRemove, - this.trailing}) + required this.selectionHandlers, + this.trailing, + required this.selectionController}) : super(key: GlobalKey<_DomainRequestsState>()); ///添加请求 @@ -366,7 +421,12 @@ class DomainRequests extends StatefulWidget { if (requestMap.containsKey(requestId)) return; var requestWidget = RequestWidget(request, - index: body.length, proxyServer: proxyServer, displayDomain: false, remove: (it) => _remove(it)); + index: body.length, + proxyServer: proxyServer, + displayDomain: false, + multiSelectController: selectionController, + selectionHandlers: selectionHandlers, + remove: (it) => _remove(it)); sortDesc ? body.addFirst(requestWidget) : body.addLast(requestWidget); if (requestId == null) { @@ -381,19 +441,19 @@ class DomainRequests extends StatefulWidget { return requestMap[response.request?.requestId ?? response.requestId]; } - setTrailing(Widget? trailing) { + void setTrailing(Widget? trailing) { var state = key as GlobalKey<_DomainRequestsState>; state.currentState?.trailing = trailing; } - _remove(RequestWidget requestWidget) { + void _remove(RequestWidget requestWidget) { if (body.remove(requestWidget)) { onRequestRemove?.call(requestWidget.request); changeState(); } } - _removeRequest(HttpRequest request) { + void _removeRequest(HttpRequest request) { var requestWidget = requestMap.remove(request.requestId); if (requestWidget != null) { _remove(requestWidget); @@ -415,6 +475,8 @@ class DomainRequests extends StatefulWidget { onDelete: onDelete, onExportHar: onExportHar, onRequestRemove: onRequestRemove, + selectionController: selectionController, + selectionHandlers: selectionHandlers, proxyServer: proxyServer); if (body != null) { headerBody.body.addAll(body); @@ -427,7 +489,7 @@ class DomainRequests extends StatefulWidget { return state.currentState?.selected == true; } - changeState() { + void changeState() { var state = key as GlobalKey<_DomainRequestsState>; state.currentState?.changeState(); } @@ -455,7 +517,7 @@ class _DomainRequestsState extends State { trailing = widget.trailing; } - changeState() { + void changeState() { //防止频繁刷新 if (!changing) { changing = true; @@ -510,7 +572,7 @@ class _DomainRequestsState extends State { } //域名右键菜单 - menu() { + void menu() { Menu menu = Menu(items: [ MenuItem( label: localizations.copyHost, @@ -580,7 +642,7 @@ class _DomainRequestsState extends State { ]); } - _delete() { + void _delete() { widget.onDelete?.call(widget.domain); widget.requestMap.clear(); widget.body.clear(); diff --git a/lib/ui/desktop/request/list.dart b/lib/ui/desktop/request/list.dart index a692b95..d2b67e6 100644 --- a/lib/ui/desktop/request/list.dart +++ b/lib/ui/desktop/request/list.dart @@ -17,6 +17,8 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/network/bin/server.dart'; @@ -25,17 +27,20 @@ import 'package:proxypin/network/channel/channel_context.dart'; import 'package:proxypin/network/channel/host_port.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_client.dart'; +import 'package:proxypin/ui/component/multi_select_controller.dart'; +import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/component/widgets.dart'; import 'package:proxypin/ui/content/panel.dart'; import 'package:proxypin/ui/desktop/request/request_sequence.dart'; import 'package:proxypin/ui/desktop/request/request.dart'; import 'package:proxypin/ui/desktop/request/search.dart'; +import 'package:proxypin/ui/component/selection_action_bar.dart'; import 'package:proxypin/utils/har.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/listenable_list.dart'; import '../../component/model/search_model.dart'; -import 'domians.dart'; +import 'domains.dart'; import 'package:proxypin/ui/desktop/request/report_servers.dart'; /// @author wanghongen @@ -56,12 +61,16 @@ class DesktopRequestListState extends State with Autom final GlobalKey requestSequenceKey = GlobalKey(); final GlobalKey domainListKey = GlobalKey(); final GlobalKey searchKey = GlobalKey(); + TabController? _tabController; //请求列表容器 ListenableList container = ListenableList(); bool sortDesc = true; + // 选择控制器 + final MultiSelectController selectionController = MultiSelectController(); + AppLocalizations get localizations => AppLocalizations.of(context)!; @override @@ -72,12 +81,15 @@ class DesktopRequestListState extends State with Autom } } + bool get isSelectionMode => selectionController.isSelectionMode; + @override bool get wantKeepAlive => true; @override void dispose() { RequestWidget.removeAutoReadByIds(container.map((request) => request.requestId)); + selectionController.clear(); super.dispose(); } @@ -90,84 +102,141 @@ class DesktopRequestListState extends State with Autom Tab(child: Text(localizations.sequence, style: const TextStyle(fontSize: 13))), ]; - return DefaultTabController( - length: tabs.length, - child: Scaffold( - appBar: AppBar( - toolbarHeight: 40, - title: SizedBox(height: 40, child: TabBar(tabs: tabs, dividerColor: Colors.transparent)), - automaticallyImplyLeading: false, - actions: [popupMenus()], - ), - bottomNavigationBar: Search(key: searchKey, onSearch: search), - body: Padding( - padding: const EdgeInsets.only(right: 5), - child: TabBarView(physics: const NeverScrollableScrollPhysics(), children: [ - DomainList( - key: domainListKey, - list: container, - panel: widget.panel, - proxyServer: widget.proxyServer, - onRemove: domainListRemove), - RequestSequence( - key: requestSequenceKey, - container: container, - proxyServer: widget.proxyServer, - onRemove: sequenceRemove), - ])))); + return FocusableActionDetector( + autofocus: true, + shortcuts: { + SingleActivator(LogicalKeyboardKey.escape): const _ClearSelectionIntent(), + }, + actions: { + _ClearSelectionIntent: CallbackAction<_ClearSelectionIntent>(onInvoke: (intent) { + if (_isTextInputFocused()) { + return null; + } + if (isSelectionMode) { + selectionController.clear(); + } + return null; + }), + }, + child: DefaultTabController( + length: tabs.length, + child: Builder(builder: (tabContext) { + _tabController = DefaultTabController.of(tabContext); + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: SizedBox(height: 40, child: TabBar(tabs: tabs, dividerColor: Colors.transparent)), + automaticallyImplyLeading: false, + actions: [popupMenus()], + ), + bottomNavigationBar: Search(key: searchKey, onSearch: search), + body: Padding( + padding: const EdgeInsets.only(right: 5), + child: Obx(() { + return Column(children: [ + if (isSelectionMode) + SelectionActionBar( + selectionController: selectionController, + onRepeat: repeatSelected, + onExport: exportSelected, + onDelete: deleteSelected), + Expanded( + child: TabBarView(physics: const NeverScrollableScrollPhysics(), children: [ + DomainList( + key: domainListKey, + list: container, + panel: widget.panel, + proxyServer: widget.proxyServer, + selectionController: selectionController, + selectionHandlers: RequestSelectionHandlers( + onRangeSelection: rangeSelectRequest, + onDeleteSelected: deleteSelected, + onRepeatSelected: repeatSelected, + onExportSelected: exportSelected, + ), + onRemove: domainListRemove, + ), + RequestSequence( + key: requestSequenceKey, + container: container, + proxyServer: widget.proxyServer, + selectionController: selectionController, + // onSelectionChanged: syncDomainSelectionVisuals, + selectionHandlers: RequestSelectionHandlers( + onRangeSelection: rangeSelectRequest, + onDeleteSelected: deleteSelected, + onRepeatSelected: repeatSelected, + onExportSelected: exportSelected, + ), + onRemove: sequenceRemove, + ), + ])), + ]); + }))); + }))); + } + + bool _isTextInputFocused() { + return FocusManager.instance.primaryFocus?.context?.widget is EditableText; } Widget popupMenus() { - return PopupMenuButton( + return PopupMenuButton<_RequestListMenuAction>( offset: const Offset(0, 32), icon: const Icon(Icons.more_vert_outlined, size: 20), + onSelected: _onMenuSelected, itemBuilder: (BuildContext context) { - return [ - CustomPopupMenuItem( - height: 37, - onTap: () => searchKey.currentState?.searchDialog(), - child: IconText( - icon: const Icon(Icons.search, size: 17), - text: localizations.search, - textStyle: const TextStyle(fontSize: 13))), - CustomPopupMenuItem( - height: 37, - onTap: () => export('ProxyPin_${DateTime.now().dateFormat()}.har'), - child: IconText( - icon: const Icon(Icons.share, size: 16), - text: localizations.viewExport, - textStyle: const TextStyle(fontSize: 13))), - CustomPopupMenuItem( - height: 37, - onTap: () => repeatAllRequests(), - child: IconText( - icon: const Icon(Icons.repeat, size: 16), - text: localizations.repeatAllRequests, - textStyle: const TextStyle(fontSize: 13))), - CustomPopupMenuItem( - height: 37, - onTap: () { - sortDesc = !sortDesc; - requestSequenceKey.currentState?.sort(sortDesc); - domainListKey.currentState?.sort(sortDesc); - }, - child: IconText( - icon: const Icon(Icons.sort, size: 16), - text: sortDesc ? localizations.timeAsc : localizations.timeDesc, - textStyle: const TextStyle(fontSize: 13))), - CustomPopupMenuItem( - height: 37, - onTap: () { - showReportServersDialog(context); - }, - child: IconText( - icon: Icon(Icons.cloud_upload_outlined, size: 16), - text: localizations.reportServers, - textStyle: TextStyle(fontSize: 13))), + return >[ + _menuItem(_RequestListMenuAction.search, + icon: const Icon(Icons.search, size: 17), text: localizations.search), + _menuItem(_RequestListMenuAction.export, + icon: const Icon(Icons.share, size: 16), text: localizations.viewExport), + _menuItem(_RequestListMenuAction.repeat, + icon: const Icon(Icons.repeat, size: 16), text: localizations.repeatAllRequests), + _menuItem(_RequestListMenuAction.select, + icon: const Icon(Icons.checklist_outlined, size: 16), text: localizations.selectAction), + _menuItem(_RequestListMenuAction.sort, + icon: const Icon(Icons.sort, size: 16), + text: sortDesc ? localizations.timeAsc : localizations.timeDesc), + _menuItem(_RequestListMenuAction.report, + icon: const Icon(Icons.cloud_upload_outlined, size: 16), text: localizations.reportServers), ]; }); } + PopupMenuEntry<_RequestListMenuAction> _menuItem(_RequestListMenuAction value, + {required Icon icon, required String text}) { + return CustomPopupMenuItem<_RequestListMenuAction>( + value: value, height: 37, child: IconText(icon: icon, text: text, textStyle: const TextStyle(fontSize: 13))); + } + + void _onMenuSelected(_RequestListMenuAction action) { + switch (action) { + case _RequestListMenuAction.search: + searchKey.currentState?.searchDialog(); + break; + case _RequestListMenuAction.export: + export('ProxyPin_${DateTime.now().dateFormat()}.har'); + break; + case _RequestListMenuAction.repeat: + repeatAllRequests(); + break; + case _RequestListMenuAction.select: + selectionController.toggleSelectionMode(); + break; + case _RequestListMenuAction.sort: + setState(() { + sortDesc = !sortDesc; + }); + requestSequenceKey.currentState?.sort(sortDesc); + domainListKey.currentState?.sort(sortDesc); + break; + case _RequestListMenuAction.report: + showReportServersDialog(context); + break; + } + } + ///添加请求 void add(Channel channel, HttpRequest request) { container.add(request); @@ -181,11 +250,19 @@ class DesktopRequestListState extends State with Autom requestSequenceKey.currentState?.addResponse(response); } + void remove(List list) { + container.removeWhere((element) => list.contains(element)); + domainListKey.currentState?.remove(list); + requestSequenceKey.currentState?.remove(list); + RequestWidget.removeAutoReadByIds(list.map((request) => request.requestId)); + } + ///移除 void domainListRemove(List list) { container.removeWhere((element) => list.contains(element)); requestSequenceKey.currentState?.remove(list); RequestWidget.removeAutoReadByIds(list.map((request) => request.requestId)); + selectionController.prune(container.map((request) => request.requestId)); } ///全部请求删除 @@ -193,6 +270,7 @@ class DesktopRequestListState extends State with Autom container.removeWhere((element) => list.contains(element)); domainListKey.currentState?.remove(list); RequestWidget.removeAutoReadByIds(list.map((request) => request.requestId)); + selectionController.prune(container.map((request) => request.requestId)); } void search(SearchModel searchModel) { @@ -212,6 +290,7 @@ class DesktopRequestListState extends State with Autom domainListKey.currentState?.clean(); requestSequenceKey.currentState?.clean(); widget.panel.change(null, null); + selectionController.clear(); }); } @@ -227,19 +306,67 @@ class DesktopRequestListState extends State with Autom requestSequenceKey.currentState?.clean(); RequestWidget.removeAutoReadByIds(removeRange.map((request) => request.requestId)); + selectionController.prune(container.map((request) => request.requestId)); + } + + void deleteSelected() { + final selectedRequests = domainListKey.currentState?.selectedRequests(); + if (selectedRequests == null || selectedRequests.isEmpty) { + return; + } + + showConfirmDialog(context, content: '${localizations.delete} ${selectedRequests.length} ${localizations.request}?', + onConfirm: () { + setState(() { + remove(selectedRequests); + selectionController.clear(); + }); + if (mounted) { + FlutterToastr.show(localizations.deleteSuccess, context); + } + }); + } + + void rangeSelectRequest(HttpRequest request) { + switch (_tabController?.index) { + case 1: + requestSequenceKey.currentState?.selectRange(request); + break; + case 0: + default: + domainListKey.currentState?.selectRange(request); + break; + } + } + + void repeatSelected() { + final selectedRequests = domainListKey.currentState?.selectedRequests(); + _repeatRequests(selectedRequests); + } + + Future exportSelected() async { + final selectedRequests = domainListKey.currentState?.selectedRequests(); + if (selectedRequests == null || selectedRequests.isEmpty) { + return; + } + + final fileName = 'ProxyPin_selected_${DateTime.now().dateFormat()}.har'; + _doExport(fileName, selectedRequests); } ///导出 Future export(String fileName) async { + //获取请求 + List? requests = currentView(); + if (requests == null) return; + _doExport(fileName, requests); + } + + Future _doExport(String fileName, List requests) async { var path = await FilePicker.platform.saveFile(fileName: fileName); if (path == null) { return; } - - //获取请求 - List? requests = currentView(); - if (requests == null) return; - var file = await File(path).create(); await Har.writeFile(requests, file, title: fileName); @@ -249,6 +376,10 @@ class DesktopRequestListState extends State with Autom ///重发所有请求 void repeatAllRequests() async { var requests = currentView(); + _repeatRequests(requests); + } + + void _repeatRequests(List? requests) async { if (requests == null) return; var localizations = AppLocalizations.of(context); @@ -270,3 +401,9 @@ class DesktopRequestListState extends State with Autom } } } + +class _ClearSelectionIntent extends Intent { + const _ClearSelectionIntent(); +} + +enum _RequestListMenuAction { search, export, repeat, select, sort, report } diff --git a/lib/ui/desktop/request/request.dart b/lib/ui/desktop/request/request.dart index 9c5b76d..8e5be73 100644 --- a/lib/ui/desktop/request/request.dart +++ b/lib/ui/desktop/request/request.dart @@ -30,6 +30,7 @@ import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_client.dart'; import 'package:proxypin/network/util/cache.dart'; import 'package:proxypin/storage/favorites.dart'; +import 'package:proxypin/ui/component/multi_select_controller.dart'; import 'package:proxypin/ui/component/multi_window.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/component/widgets.dart'; @@ -61,10 +62,19 @@ class RequestWidget extends StatefulWidget { final ProxyServer proxyServer; final Function(RequestWidget)? remove; final Widget? trailing; + final MultiSelectController multiSelectController; + final RequestSelectionHandlers selectionHandlers; RequestWidget(this.request, - {Key? key, required this.proxyServer, this.remove, this.displayDomain = true, this.trailing, required this.index}) - : super(key: GlobalKey<_RequestWidgetState>()); + {Key? key, + required this.proxyServer, + this.remove, + this.displayDomain = true, + this.trailing, + required this.selectionHandlers, + required this.index, + required this.multiSelectController}) + : super(key: key ?? GlobalKey<_RequestWidgetState>()); @override State createState() => _RequestWidgetState(); @@ -75,10 +85,14 @@ class RequestWidget extends StatefulWidget { state.currentState?.changeState(); } + void changeState() { + var state = key as GlobalKey<_RequestWidgetState>; + state.currentState?.changeState(); + } + static void removeAutoReadByIds(Iterable requestIds) { _RequestWidgetState.removeAutoReadByIds(requestIds); } - } class _RequestWidgetState extends State { @@ -101,6 +115,10 @@ class _RequestWidgetState extends State { AppLocalizations get localizations => AppLocalizations.of(context)!; + bool get selectionMode => widget.multiSelectController.isSelectionMode; + + int get selectionCount => widget.multiSelectController.selectedCount; + @override void initState() { super.initState(); @@ -118,15 +136,20 @@ class _RequestWidgetState extends State { var packagesSize = getPackagesSize(request, response); var requestColor = color(path); - + bool selectedInSelectionMode = widget.multiSelectController.contains(request.requestId); return GestureDetector( + onLongPress: () { + if (!selectionMode) { + widget.multiSelectController.enterSelectionMode(widget.request.requestId); + } + }, onSecondaryTap: contextualMenu, child: ListTile( minLeadingWidth: 5, textColor: requestColor, selectedColor: requestColor, selectedTileColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - leading: getIcon(widget.response.get() ?? widget.request.response, color: requestColor), + leading: _leading(requestColor), trailing: widget.trailing, title: Text(title.fixAutoLines(), overflow: TextOverflow.ellipsis, maxLines: 2), subtitle: Container( @@ -142,13 +165,29 @@ class _RequestWidgetState extends State { style: const TextStyle(fontSize: 11, color: Colors.grey)) ], ))), - selected: selected, + selected: selected || selectedInSelectionMode, dense: true, visualDensity: const VisualDensity(vertical: -4), - contentPadding: const EdgeInsets.only(left: 28), + contentPadding: EdgeInsets.only(left: selectedInSelectionMode ? 6 : 28), onTap: onClick)); } + Widget _leading(Color? requestColor) { + bool selectedInSelectionMode = widget.multiSelectController.contains(widget.request.requestId); + + var icon = getIcon(widget.response.get() ?? widget.request.response, color: requestColor); + if (!selectedInSelectionMode) { + return icon; + } + + return Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(selectedInSelectionMode ? Icons.check_box_outlined : Icons.check_box_outline_blank_outlined, + size: 18, color: selectedInSelectionMode ? Theme.of(context).colorScheme.primary : Colors.grey), + const SizedBox(width: 4), + icon, + ]); + } + Color? color(String url) { if (highlightColor != null) { return highlightColor; @@ -167,129 +206,195 @@ class _RequestWidgetState extends State { } void contextualMenu() { - Menu menu = Menu(items: [ - MenuItem( - label: localizations.copyUrl, - onClick: (_) { - var requestUrl = widget.request.requestUrl; - Clipboard.setData(ClipboardData(text: requestUrl)) - .then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context)); - }), - MenuItem( - label: localizations.copy, - type: 'submenu', - submenu: Menu(items: [ - MenuItem( - label: localizations.copyCurl, - onClick: (_) { - Clipboard.setData(ClipboardData(text: curlRequest(widget.request))) - .then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context)); - }), - MenuItem( - label: localizations.copyRawRequest, - onClick: (_) { - Clipboard.setData(ClipboardData(text: copyRawRequest(widget.request))) - .then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context)); - }), - MenuItem( - label: localizations.copyRequestResponse, - onClick: (_) { - Clipboard.setData(ClipboardData(text: copyRequest(widget.request, widget.response.get()))) - .then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context)); - }), - MenuItem( - label: localizations.copyAsPythonRequests, - onClick: (_) { - Clipboard.setData(ClipboardData(text: copyAsPythonRequests(widget.request))) - .then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context)); - }, - ), - MenuItem( - label: localizations.copyAsFetch, - onClick: (_) { - Clipboard.setData(ClipboardData(text: copyAsFetch(widget.request))) - .then((value) => FlutterToastr.show(localizations.copied, rootNavigator: true, context)); - }, - ), - ])), - MenuItem.separator(), - MenuItem( - label: localizations.openNewWindow, - onClick: (_) { - openDetailInNewWindow(); - }), - MenuItem.separator(), - MenuItem( - label: localizations.export, - type: 'submenu', - submenu: Menu(items: [ - MenuItem(label: localizations.request, onClick: (_) => exportRequest(widget.request)), - MenuItem(label: localizations.requestBody, onClick: (_) => exportRequestBody(widget.request)), - MenuItem.separator(), - MenuItem(label: localizations.response, onClick: (_) => exportResponse(widget.response.get())), - MenuItem(label: localizations.responseBody, onClick: (_) => exportResponseBody(widget.response.get())), - MenuItem.separator(), - MenuItem(label: "HAR", onClick: (_) => exportHar(widget.request)), - ])), - MenuItem.separator(), - MenuItem(label: localizations.repeat, onClick: (_) => onRepeat(widget.request)), - MenuItem(label: localizations.customRepeat, onClick: (_) => showCustomRepeat(widget.request)), - MenuItem( - label: localizations.editRequest, - onClick: (_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - requestEdit(); - }); - }), - MenuItem.separator(), - MenuItem(label: localizations.requestRewrite, onClick: (_) => showRequestRewriteDialog(context, widget.request)), - MenuItem( - label: localizations.requestMap, - onClick: (_) async { - showDialog( - context: context, - builder: (context) => - RequestMapEdit(url: widget.request.domainPath, title: widget.request.hostAndPort?.host)); - }), - MenuItem( - label: localizations.script, - onClick: (_) async { - var scriptManager = await ScriptManager.instance; - var url = widget.request.domainPath; - var scriptItem = (scriptManager).list.firstWhereOrNull((it) => it.urls.contains(url)); + popUpContextMenu(selectionMode && selectionCount > 1 ? _batchMenu() : _requestMenu()); + } - String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem); - if (!mounted) return; - showDialog( - context: context, - builder: (context) => ScriptEdit( - scriptItem: scriptItem, script: script, url: url, title: widget.request.hostAndPort?.host)); - }), + Menu _batchMenu() { + return Menu(items: [ + _menuAction(localizations.repeat, _RequestMenuAction.batchRepeat), + _menuAction(localizations.export, _RequestMenuAction.batchExport), MenuItem.separator(), - MenuItem( - label: localizations.favorite, - onClick: (_) { - FavoriteStorage.addFavorite(widget.request); - FlutterToastr.show(localizations.operationSuccess, context, rootNavigator: true); - }), - MenuItem( - label: localizations.highlight, - type: 'submenu', - submenu: highlightMenu(), - onClick: (_) { - setState(() { - highlightColor = Colors.red; - }); - }), + _menuAction(localizations.delete, _RequestMenuAction.batchDelete), MenuItem.separator(), - MenuItem( - label: localizations.delete, - onClick: (_) { - widget.remove?.call(widget); - }), + _menuAction(localizations.cancel, _RequestMenuAction.batchCancel), ]); + } - popUpContextMenu(menu); + Menu _requestMenu() { + return Menu(items: [ + _menuAction(localizations.copyUrl, _RequestMenuAction.copyUrl), + MenuItem(label: localizations.copy, type: 'submenu', submenu: _copySubmenu()), + MenuItem.separator(), + _menuAction(localizations.openNewWindow, _RequestMenuAction.openNewWindow), + MenuItem.separator(), + MenuItem(label: localizations.export, type: 'submenu', submenu: _exportSubmenu()), + MenuItem.separator(), + _menuAction(localizations.repeat, _RequestMenuAction.repeat), + _menuAction(localizations.customRepeat, _RequestMenuAction.customRepeat), + _menuAction(localizations.editRequest, _RequestMenuAction.editRequest), + MenuItem.separator(), + _menuAction(localizations.requestRewrite, _RequestMenuAction.requestRewrite), + _menuAction(localizations.requestMap, _RequestMenuAction.requestMap), + _menuAction(localizations.script, _RequestMenuAction.script), + MenuItem.separator(), + _menuAction(localizations.favorite, _RequestMenuAction.favorite), + MenuItem(label: localizations.highlight, type: 'submenu', submenu: highlightMenu()), + MenuItem.separator(), + _menuAction(localizations.selectAction, _RequestMenuAction.select), + MenuItem.separator(), + _menuAction(localizations.delete, _RequestMenuAction.delete), + ]); + } + + Menu _copySubmenu() { + return Menu(items: [ + _copyMenuAction(localizations.copyCurl, _RequestCopyMenuAction.curl), + _copyMenuAction(localizations.copyRawRequest, _RequestCopyMenuAction.rawRequest), + _copyMenuAction(localizations.copyRequestResponse, _RequestCopyMenuAction.requestResponse), + _copyMenuAction(localizations.copyAsPythonRequests, _RequestCopyMenuAction.pythonRequests), + _copyMenuAction(localizations.copyAsFetch, _RequestCopyMenuAction.fetch), + ]); + } + + Menu _exportSubmenu() { + return Menu(items: [ + _exportMenuAction(localizations.request, _RequestExportMenuAction.request), + _exportMenuAction(localizations.requestBody, _RequestExportMenuAction.requestBody), + MenuItem.separator(), + _exportMenuAction(localizations.response, _RequestExportMenuAction.response), + _exportMenuAction(localizations.responseBody, _RequestExportMenuAction.responseBody), + MenuItem.separator(), + _exportMenuAction('HAR', _RequestExportMenuAction.har), + ]); + } + + MenuItem _menuAction(String label, _RequestMenuAction action) { + return MenuItem(label: label, onClick: (_) => _onMenuAction(action)); + } + + MenuItem _copyMenuAction(String label, _RequestCopyMenuAction action) { + return MenuItem(label: label, onClick: (_) => _onCopyMenuAction(action)); + } + + MenuItem _exportMenuAction(String label, _RequestExportMenuAction action) { + return MenuItem(label: label, onClick: (_) => _onExportMenuAction(action)); + } + + Future _onMenuAction(_RequestMenuAction action) async { + switch (action) { + case _RequestMenuAction.copyUrl: + await _copyText(widget.request.requestUrl); + break; + case _RequestMenuAction.openNewWindow: + openDetailInNewWindow(); + break; + case _RequestMenuAction.repeat: + onRepeat(widget.request); + break; + case _RequestMenuAction.customRepeat: + await showCustomRepeat(widget.request); + break; + case _RequestMenuAction.editRequest: + WidgetsBinding.instance.addPostFrameCallback((_) { + requestEdit(); + }); + break; + case _RequestMenuAction.requestRewrite: + showRequestRewriteDialog(context, widget.request); + break; + case _RequestMenuAction.requestMap: + showDialog( + context: context, + builder: (context) => + RequestMapEdit(url: widget.request.domainPath, title: widget.request.hostAndPort?.host)); + break; + case _RequestMenuAction.script: + await _openScriptDialog(); + break; + case _RequestMenuAction.favorite: + FavoriteStorage.addFavorite(widget.request); + FlutterToastr.show(localizations.operationSuccess, context, rootNavigator: true); + break; + case _RequestMenuAction.select: + widget.multiSelectController.selectOnly(widget.request.requestId); + break; + case _RequestMenuAction.delete: + widget.remove?.call(widget); + break; + case _RequestMenuAction.batchRepeat: + widget.selectionHandlers.onRepeatSelected?.call(); + break; + case _RequestMenuAction.batchExport: + widget.selectionHandlers.onExportSelected?.call(); + break; + case _RequestMenuAction.batchDelete: + widget.selectionHandlers.onDeleteSelected?.call(); + break; + case _RequestMenuAction.batchCancel: + widget.multiSelectController.clear(); + break; + } + } + + Future _onCopyMenuAction(_RequestCopyMenuAction action) async { + switch (action) { + case _RequestCopyMenuAction.curl: + await _copyText(curlRequest(widget.request)); + break; + case _RequestCopyMenuAction.rawRequest: + await _copyText(copyRawRequest(widget.request)); + break; + case _RequestCopyMenuAction.requestResponse: + await _copyText(copyRequest(widget.request, widget.response.get())); + break; + case _RequestCopyMenuAction.pythonRequests: + await _copyText(copyAsPythonRequests(widget.request)); + break; + case _RequestCopyMenuAction.fetch: + await _copyText(copyAsFetch(widget.request)); + break; + } + } + + void _onExportMenuAction(_RequestExportMenuAction action) { + switch (action) { + case _RequestExportMenuAction.request: + exportRequest(widget.request); + break; + case _RequestExportMenuAction.requestBody: + exportRequestBody(widget.request); + break; + case _RequestExportMenuAction.response: + exportResponse(widget.response.get()); + break; + case _RequestExportMenuAction.responseBody: + exportResponseBody(widget.response.get()); + break; + case _RequestExportMenuAction.har: + exportHar(widget.request); + break; + } + } + + Future _openScriptDialog() async { + var scriptManager = await ScriptManager.instance; + var url = widget.request.domainPath; + var scriptItem = scriptManager.list.firstWhereOrNull((it) => it.urls.contains(url)); + String? script = scriptItem == null ? null : await scriptManager.getScript(scriptItem); + if (!mounted) { + return; + } + showDialog( + context: context, + builder: (context) => + ScriptEdit(scriptItem: scriptItem, script: script, url: url, title: widget.request.hostAndPort?.host)); + } + + Future _copyText(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (mounted) { + FlutterToastr.show(localizations.copied, rootNavigator: true, context); + } } ///高亮 @@ -415,6 +520,22 @@ class _RequestWidgetState extends State { //点击事件 void onClick() { + final keyboard = HardwareKeyboard.instance; + final useToggleSelection = keyboard.isMetaPressed || keyboard.isControlPressed; + final useRangeSelection = keyboard.isShiftPressed; + + if (useRangeSelection) { + widget.selectionHandlers.onRangeSelection?.call(widget.request); + return; + } + + if (selectionMode || useToggleSelection) { + setState(() { + widget.multiSelectController.toggle(widget.request.requestId); + }); + return; + } + if (!selected) { setState(() { selected = true; @@ -436,3 +557,39 @@ class _RequestWidgetState extends State { NetworkTabController.current?.change(widget.request, widget.response.get() ?? widget.request.response); } } + +class RequestSelectionHandlers { + final Function(HttpRequest request)? onRangeSelection; + final VoidCallback? onDeleteSelected; + final VoidCallback? onRepeatSelected; + final VoidCallback? onExportSelected; + + const RequestSelectionHandlers({ + this.onRangeSelection, + this.onDeleteSelected, + this.onRepeatSelected, + this.onExportSelected, + }); +} + +enum _RequestMenuAction { + copyUrl, + openNewWindow, + repeat, + customRepeat, + editRequest, + requestRewrite, + requestMap, + script, + favorite, + select, + delete, + batchRepeat, + batchExport, + batchDelete, + batchCancel, +} + +enum _RequestCopyMenuAction { curl, rawRequest, requestResponse, pythonRequests, fetch } + +enum _RequestExportMenuAction { request, requestBody, response, responseBody, har } diff --git a/lib/ui/desktop/request/request_sequence.dart b/lib/ui/desktop/request/request_sequence.dart index 5e58fa2..b23a691 100644 --- a/lib/ui/desktop/request/request_sequence.dart +++ b/lib/ui/desktop/request/request_sequence.dart @@ -17,9 +17,11 @@ import 'dart:collection'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/bin/configuration.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/http/http.dart'; +import 'package:proxypin/ui/component/multi_select_controller.dart'; import 'package:proxypin/ui/component/utils.dart'; import 'package:proxypin/ui/desktop/request/request.dart'; import 'package:proxypin/utils/keyword_highlight.dart'; @@ -34,9 +36,17 @@ class RequestSequence extends StatefulWidget { final ProxyServer proxyServer; final bool displayDomain; final Function(List)? onRemove; + final MultiSelectController selectionController; + final RequestSelectionHandlers selectionHandlers; const RequestSequence( - {super.key, required this.container, required this.proxyServer, this.displayDomain = true, this.onRemove}); + {super.key, + required this.container, + required this.proxyServer, + this.displayDomain = true, + this.onRemove, + required this.selectionController, + required this.selectionHandlers}); @override State createState() { @@ -49,15 +59,21 @@ class RequestSequenceState extends State with AutomaticKeepAliv ///显示的请求列表 最新的在前面 Queue view = Queue(); + final Map rowKeys = {}; bool changing = false; bool sortDesc = true; + AppLocalizations get localizations => AppLocalizations.of(context)!; + //搜索的内容 SearchModel? searchModel; //关键词高亮监听 late VoidCallback highlightListener; + late MultiSelectListener selectionListener; + + MultiSelectController get selectionController => widget.selectionController; @override void initState() { @@ -72,6 +88,14 @@ class RequestSequenceState extends State with AutomaticKeepAliv }); }; KeywordHighlights.addListener(highlightListener); + + selectionListener = MultiSelectListener((items) { + if (!mounted) { + return; + } + _refreshChangedRows(items); + }); + selectionController.selectedIds.addListener(selectionListener); } void changeState() { @@ -91,6 +115,7 @@ class RequestSequenceState extends State with AutomaticKeepAliv @override void dispose() { + selectionController.selectedIds.removeListener(selectionListener); KeywordHighlights.removeListener(highlightListener); super.dispose(); } @@ -98,26 +123,34 @@ class RequestSequenceState extends State with AutomaticKeepAliv @override Widget build(BuildContext context) { super.build(context); + return ListView.separated( - cacheExtent: 1000, - separatorBuilder: (context, index) => Divider(thickness: 0.2, height: 0, color: Theme.of(context).dividerColor), - itemCount: view.length, - itemBuilder: (context, index) { - return RequestWidget( - key: ValueKey(view.elementAt(index).requestId), - view.elementAt(index), - index: sortDesc ? view.length - index : index, - trailing: appIcon(view.elementAt(index)), - proxyServer: widget.proxyServer, - displayDomain: widget.displayDomain, - remove: (requestWidget) { - setState(() { - view.remove(requestWidget.request); - }); + cacheExtent: 1000, + separatorBuilder: (context, index) => Divider(thickness: 0.2, height: 0, color: Theme.of(context).dividerColor), + itemCount: view.length, + itemBuilder: (context, index) { + final request = view.elementAt(index); + final requestId = request.requestId; + final key = rowKeys.putIfAbsent(requestId, () => GlobalKey()); + return RequestWidget( + key: key, + request, + index: sortDesc ? view.length - index : index, + trailing: appIcon(request), + proxyServer: widget.proxyServer, + displayDomain: widget.displayDomain, + multiSelectController: selectionController, + selectionHandlers: widget.selectionHandlers, + remove: (requestWidget) { + setState(() { + view.remove(requestWidget.request); + rowKeys.remove(requestWidget.request.requestId); widget.onRemove?.call([requestWidget.request]); - }, - ); - }); + }); + }, + ); + }, + ); } Widget? appIcon(HttpRequest request) { @@ -156,6 +189,8 @@ class RequestSequenceState extends State with AutomaticKeepAliv view.addLast(request); } + rowKeys.putIfAbsent(request.requestId, () => GlobalKey()); + changeState(); } @@ -170,6 +205,7 @@ class RequestSequenceState extends State with AutomaticKeepAliv if (searchModel?.filter(response.request!, response) == true) { if (!view.contains(response.request)) { view.addFirst(response.request!); + rowKeys.putIfAbsent(response.request!.requestId, () => GlobalKey()); changeState(); } } @@ -183,22 +219,34 @@ class RequestSequenceState extends State with AutomaticKeepAliv } else { view = Queue.of(widget.container.where((it) => searchModel.filter(it, it.response)).toList().reversed); } + rowKeys.removeWhere((requestId, _) => !view.any((request) => request.requestId == requestId)); + selectionController.prune(view.map((request) => request.requestId)); setState(() {}); } void remove(List list) { setState(() { view.removeWhere((element) => list.contains(element)); + for (final request in list) { + rowKeys.remove(request.requestId); + } }); } void clean() { setState(() { view.clear(); + rowKeys.clear(); view.addAll(widget.container.source.reversed); }); } + void selectRange(HttpRequest request) { + setState(() { + selectionController.selectRange(view.map((item) => item.requestId).toList(), request.requestId); + }); + } + ///排序 void sort(bool desc) { sortDesc = desc; @@ -206,4 +254,15 @@ class RequestSequenceState extends State with AutomaticKeepAliv view = Queue.of(view.toList().reversed); }); } + + void _refreshChangedRows(List changedIds) { + if (changedIds.isEmpty) { + return; + } + + for (final requestId in changedIds) { + final key = rowKeys[requestId]; + key?.currentState?.setState(() {}); + } + } } diff --git a/lib/ui/desktop/toolbar/toolbar.dart b/lib/ui/desktop/toolbar/toolbar.dart index 85a2a03..57eaa5f 100644 --- a/lib/ui/desktop/toolbar/toolbar.dart +++ b/lib/ui/desktop/toolbar/toolbar.dart @@ -52,6 +52,10 @@ class _ToolbarState extends State { } bool onKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) { + return false; + } + if (HardwareKeyboard.instance.isLogicalKeyPressed(LogicalKeyboardKey.escape)) { if (ModalRoute.of(context)?.isCurrent == false) { Navigator.maybePop(context); diff --git a/lib/utils/listenable_list.dart b/lib/utils/listenable_list.dart index 615b99c..4409810 100644 --- a/lib/utils/listenable_list.dart +++ b/lib/utils/listenable_list.dart @@ -15,7 +15,7 @@ */ abstract class ListenerListEvent { /// 监听的源 - sourceAware(List source) {} + void sourceAware(List source) {} void onAdd(T item); @@ -25,7 +25,7 @@ abstract class ListenerListEvent { void onBatchRemove(List items); - clear(); + void clear(List items); } class OnchangeListEvent extends ListenerListEvent { @@ -46,7 +46,7 @@ class OnchangeListEvent extends ListenerListEvent { void onBatchRemove(List items) => onChange.call(); @override - clear() => onChange.call(); + clear(List items) => onChange.call(); } /// 可监听list @@ -85,6 +85,15 @@ class ListenableList extends Iterable { return source.sublist(start, end); } + void addAll(Iterable items) { + source.addAll(items); + for (var element in _listeners) { + for (var item in items) { + element.onAdd(item); + } + } + } + List removeRange(int start, int end) { final normalizedEnd = end > source.length ? source.length : end; if (start < 0 || start >= normalizedEnd) { @@ -94,7 +103,7 @@ class ListenableList extends Iterable { final removed = source.sublist(start, normalizedEnd); source.removeRange(start, normalizedEnd); for (var element in _listeners) { - element.clear(); + element.clear(removed); } return removed; } @@ -134,9 +143,10 @@ class ListenableList extends Iterable { } void clear() { + final removed = List.from(source); source.clear(); for (var element in _listeners) { - element.clear(); + element.clear(removed); } } diff --git a/test/multi_select_controller_test.dart b/test/multi_select_controller_test.dart new file mode 100644 index 0000000..281ccfb --- /dev/null +++ b/test/multi_select_controller_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:proxypin/ui/component/multi_select_controller.dart'; + +void main() { + group('MultiSelectController', () { + test('toggle enables and clears selection mode', () { + final controller = MultiSelectController(); + + controller.toggle('a'); + expect(controller.isSelectionMode, isTrue); + expect(controller.selectedIds, {'a'}); + + controller.toggle('a'); + expect(controller.isSelectionMode, isFalse); + expect(controller.selectedIds, isEmpty); + }); + + test('selectRange uses anchor item', () { + final controller = MultiSelectController(); + controller.selectOnly('b'); + + controller.selectRange(['a', 'b', 'c', 'd'], 'd'); + + expect(controller.selectedIds, {'b', 'c', 'd'}); + }); + + test('selectAll and prune keep only visible ids', () { + final controller = MultiSelectController(); + + controller.prune(['b', 'c', 'd']); + + expect(controller.isSelectionMode, isTrue); + expect(controller.selectedIds, {'b', 'c'}); + }); + + test('prune clears selection mode when all selected ids disappear', () { + final controller = MultiSelectController(); + + controller.prune(['c']); + + expect(controller.isSelectionMode, isFalse); + expect(controller.selectedIds, isEmpty); + }); + }); +} +