Add autocomplete suggestions for HTTP headers in request editor

This commit is contained in:
wanghongenpin
2026-02-27 01:00:03 +08:00
parent 905d8932bd
commit 6bf5063bed
4 changed files with 412 additions and 59 deletions

View File

@@ -27,6 +27,84 @@ class HttpHeaders {
static const String Cookie = "Cookie";
static const String PROXY_AUTHORIZATION = "Proxy-Authorization";
static const List<String> commonHeaderKeys = [
'Accept',
'Accept-Charset',
'Accept-Encoding',
'Accept-Language',
'Accept-Ranges',
'Authorization',
'Cache-Control',
'Connection',
'Content-Type',
'Content-Length',
'Content-Encoding',
'Cookie',
'Date',
'Expect',
'From',
'Host',
'If-Match',
'If-Modified-Since',
'If-None-Match',
'If-Range',
'If-Unmodified-Since',
'Max-Forwards',
'Origin',
'Pragma',
'Proxy-Authorization',
'Range',
'Referer',
'TE',
'Upgrade',
'User-Agent',
'Via',
'Warning',
'X-Requested-With',
'DNT',
'X-Forwarded-For',
'X-Forwarded-Host',
'X-Forwarded-Proto',
'Front-End-Https',
'X-Http-Method-Override',
'X-ATT-DeviceId',
'X-Wap-Profile',
'Proxy-Connection',
'X-UIDH',
'X-Csrf-Token',
'X-Request-ID',
'X-Correlation-ID',
'Save-Data'
];
static const Map<String, List<String>> commonHeaderValues = {
'Accept': [
'application/json, text/plain, */*',
'application/xml, text/xml, */*',
'text/html, application/xhtml+xml, */*',
'*/*'
],
'Accept-Charset': ['utf-8, iso-8859-1;q=0.5', 'utf-8'],
'Accept-Encoding': ['gzip, deflate, br', 'gzip, deflate'],
'Accept-Language': ['en-US,en;q=0.9', 'zh-CN,zh;q=0.9'],
'Cache-Control': ['no-cache', 'max-age=0', 'no-store'],
'Connection': ['keep-alive', 'close'],
'Content-Type': [
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
'text/html',
'application/xml'
],
'User-Agent': [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36',
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Android 11; Mobile; rv:68.0) Gecko/68.0 Firefox/68.0'
],
};
final LinkedHashMap<String, List<String>> _headers = LinkedHashMap<String, List<String>>();
// 由小写标头名称键入的原始标头名称。

View File

@@ -208,7 +208,7 @@ class RequestEditorState extends State<RequestEditor> {
]))
]),
message: response,
readOnly: widget.source == RequestEditorSource.breakpointRequest))
readOnly: widget.source != RequestEditorSource.breakpointResponse))
],
);
}),
@@ -377,7 +377,7 @@ class _HttpState extends State<_HttpWidget> {
}
}
change(HttpMessage? message) {
void change(HttpMessage? message) {
this.message = message;
body?.text = message?.bodyAsString ?? '';
headerKey.currentState?.refreshParam(message?.headers.getHeaders());
@@ -416,7 +416,8 @@ class _HttpState extends State<_HttpWidget> {
KeyValWidget(
key: headerKey,
params: message?.headers.getHeaders() ?? initHeader,
readOnly: widget.readOnly),
readOnly: widget.readOnly,
suggestions: HttpHeaders.commonHeaderKeys),
_body()
],
)),
@@ -521,6 +522,8 @@ class KeyVal {
bool enabled = true;
TextEditingController key;
TextEditingController value;
FocusNode? keyFocusNode;
FocusNode? valueFocusNode;
KeyVal(this.key, this.value);
}
@@ -530,8 +533,9 @@ class KeyValWidget extends StatefulWidget {
final Map<String, List<String>>? params;
final bool readOnly; //只读
final UrlQueryNotifier? paramNotifier;
final List<String>? suggestions;
const KeyValWidget({super.key, this.params, this.readOnly = false, this.paramNotifier});
const KeyValWidget({super.key, this.params, this.readOnly = false, this.paramNotifier, this.suggestions});
@override
State<StatefulWidget> createState() => KeyValState();
@@ -570,7 +574,7 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
}
//监听url发生变化 更改表单
onChange(String value) {
void onChange(String value) {
var query = value.split("&");
int index = 0;
while (index < query.length) {
@@ -591,7 +595,7 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
setState(() {});
}
notifierChange() {
void notifierChange() {
if (widget.paramNotifier == null) return;
String query = _params
.where((e) => e.enabled && e.key.text.isNotEmpty)
@@ -600,7 +604,7 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
widget.paramNotifier?.onParamChange(query);
}
clear() {
void clear() {
for (var element in _params) {
element.key.dispose();
element.value.dispose();
@@ -609,7 +613,7 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
}
//刷新param
refreshParam(Map<String, List<String>>? headers) {
void refreshParam(Map<String, List<String>>? headers) {
clear();
setState(() {
headers?.forEach((name, values) {
@@ -692,13 +696,103 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
return list;
}
Widget _cell(TextEditingController val, {bool isKey = false}) {
Widget _cell(KeyVal keyVal,
{bool isKey = false,
FocusNode? focusNode,
List<String>? suggestions,
Map<String, List<String>>? valueSuggestions}) {
TextEditingController textController = isKey ? keyVal.key : keyVal.value;
if (!widget.readOnly && (suggestions != null || valueSuggestions != null)) {
return Container(
padding: const EdgeInsets.only(right: 5),
child: RawAutocomplete<String>(
textEditingController: textController,
focusNode: focusNode,
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
var currentSuggestions = suggestions;
if (!isKey && valueSuggestions?.containsKey(keyVal.key.text) == true) {
currentSuggestions = valueSuggestions![keyVal.key.text];
}
if (currentSuggestions == null) {
return const Iterable<String>.empty();
}
return currentSuggestions.where((String option) {
return option.toLowerCase().contains(textEditingValue.text.toLowerCase());
});
},
onSelected: (String selection) {
textController.text = selection;
notifierChange();
},
fieldViewBuilder: (BuildContext context, TextEditingController textEditingController,
FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: fieldFocusNode,
onFieldSubmitted: (String value) {
onFieldSubmitted();
},
onChanged: (val) {
if (isKey) setState(() {});
notifierChange();
},
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
minLines: 1,
maxLines: 3,
decoration: InputDecoration(
isDense: true,
hintStyle: const TextStyle(color: Colors.grey),
contentPadding: const EdgeInsets.fromLTRB(5, 13, 5, 13),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 1.5)),
border: InputBorder.none,
hintText: isKey ? "Key" : "Value"));
},
optionsViewBuilder:
(BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final String option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Container(
padding: const EdgeInsets.all(10.0),
child: _buildHighlightText(option, textController.text),
),
);
},
),
),
),
);
},
));
}
return Container(
padding: const EdgeInsets.only(right: 5),
child: TextFormField(
readOnly: widget.readOnly,
style: TextStyle(fontSize: 13, fontWeight: isKey ? FontWeight.w500 : null),
controller: val,
controller: textController,
onChanged: (val) => notifierChange(),
minLines: 1,
maxLines: 3,
@@ -715,6 +809,16 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
}
Widget _row(KeyVal keyVal, Widget? op) {
if (widget.suggestions != null) {
keyVal.keyFocusNode ??= FocusNode();
}
Map<String, List<String>>? valueSuggestions;
if (widget.suggestions != null) {
keyVal.valueFocusNode ??= FocusNode();
valueSuggestions = HttpHeaders.commonHeaderValues;
}
return Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
if (op != null)
Checkbox(
@@ -726,11 +830,31 @@ class KeyValState extends State<KeyValWidget> with AutomaticKeepAliveClientMixin
notifierChange();
}),
Container(width: 5),
Expanded(flex: 4, child: _cell(keyVal.key, isKey: true)),
Expanded(
flex: 4, child: _cell(keyVal, isKey: true, suggestions: widget.suggestions, focusNode: keyVal.keyFocusNode)),
const Text(":", style: TextStyle(color: Colors.deepOrangeAccent)),
const SizedBox(width: 8),
Expanded(flex: 6, child: _cell(keyVal.value)),
Expanded(flex: 6, child: _cell(keyVal, focusNode: keyVal.valueFocusNode, valueSuggestions: valueSuggestions)),
op ?? const SizedBox()
]);
}
Widget _buildHighlightText(String text, String query) {
if (query.isEmpty) {
return Text(text);
}
int index = text.toLowerCase().indexOf(query.toLowerCase());
if (index < 0) {
return Text(text);
}
return Text.rich(TextSpan(children: [
TextSpan(text: text.substring(0, index)),
TextSpan(
text: text.substring(index, index + query.length),
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)),
TextSpan(text: text.substring(index + query.length))
]));
}
}

View File

@@ -34,7 +34,6 @@ import 'package:proxypin/ui/mobile/request/request_editor_source.dart';
import '../../component/http_method_popup.dart';
/// @author wanghongen
class MobileRequestEditor extends StatefulWidget {
final HttpRequest? request;
@@ -92,7 +91,10 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
void initState() {
super.initState();
tabController = TabController(length: tabs.length, vsync: this, initialIndex: widget.source == RequestEditorSource.breakpointResponse ? 1 : 0);
tabController = TabController(
length: tabs.length,
vsync: this,
initialIndex: widget.source == RequestEditorSource.breakpointResponse ? 1 : 0);
request = widget.request;
response = widget.response;
if (widget.request == null) {
@@ -210,7 +212,7 @@ class RequestEditorState extends State<MobileRequestEditor> with SingleTickerPro
style: TextStyle(
color: response?.status.isSuccessful() == true ? Colors.blue : Colors.red))
]),
readOnly: widget.source == RequestEditorSource.breakpointRequest,
readOnly: widget.source != RequestEditorSource.breakpointResponse,
message: response);
}),
],
@@ -333,7 +335,7 @@ class _HttpState extends State<_HttpWidget> with AutomaticKeepAliveClientMixin {
}
}
change(HttpMessage? message) {
void change(HttpMessage? message) {
this.message = message;
body = message?.bodyAsString;
headerKey.currentState?.refreshParam(message?.headers.getHeaders());
@@ -367,6 +369,7 @@ class _HttpState extends State<_HttpWidget> with AutomaticKeepAliveClientMixin {
title: "Headers",
params: message?.headers.getHeaders() ?? initHeader,
key: headerKey,
suggestions: HttpHeaders.commonHeaderKeys,
readOnly: widget.readOnly),
// 请求头
const SizedBox(height: 10),
@@ -492,9 +495,16 @@ class KeyValWidget extends StatefulWidget {
final bool readOnly; //只读
final UrlQueryNotifier? paramNotifier;
final bool expanded;
final List<String>? suggestions;
const KeyValWidget(
{super.key, this.params, this.readOnly = false, this.paramNotifier, required this.title, this.expanded = true});
{super.key,
this.params,
this.readOnly = false,
this.paramNotifier,
required this.title,
this.expanded = true,
this.suggestions});
@override
State<StatefulWidget> createState() {
@@ -523,7 +533,7 @@ class KeyValState extends State<KeyValWidget> {
}
//监听url发生变化 更改表单
onChange(String value) {
void onChange(String value) {
var query = value.split("&");
int index = 0;
while (index < query.length) {
@@ -544,7 +554,7 @@ class KeyValState extends State<KeyValWidget> {
setState(() {});
}
notifierChange() {
void notifierChange() {
if (widget.paramNotifier == null) return;
String query = _params
.where((e) => e.enabled && e.key.isNotEmpty)
@@ -568,7 +578,7 @@ class KeyValState extends State<KeyValWidget> {
}
//刷新param
refreshParam(Map<String, List<String>>? headers) {
void refreshParam(Map<String, List<String>>? headers) {
_params.clear();
setState(() {
headers?.forEach((name, values) {
@@ -630,7 +640,7 @@ class KeyValState extends State<KeyValWidget> {
}
/// 修改请求头
modifyParam(KeyVal keyVal) {
void modifyParam(KeyVal keyVal) {
//隐藏输入框焦点
hideKeyword(context);
String headerName = keyVal.key;
@@ -638,42 +648,164 @@ class KeyValState extends State<KeyValWidget> {
showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
titlePadding: const EdgeInsets.only(left: 25, top: 10),
actionsPadding: const EdgeInsets.only(right: 10, bottom: 10),
title: Text(localizations.modifyRequestHeader, style: const TextStyle(fontSize: 18)),
content: Wrap(
children: [
TextFormField(
minLines: 1,
maxLines: 3,
initialValue: headerName,
decoration: InputDecoration(labelText: localizations.headerName),
onChanged: (value) => headerName = value,
),
TextFormField(
minLines: 1,
maxLines: 8,
initialValue: val,
decoration: InputDecoration(labelText: localizations.value),
onChanged: (value) => val = value,
)
return StatefulBuilder(builder: (context, setState) {
return AlertDialog(
titlePadding: const EdgeInsets.only(left: 25, top: 10),
actionsPadding: const EdgeInsets.only(right: 10, bottom: 10),
title: Text(localizations.modifyRequestHeader, style: const TextStyle(fontSize: 18)),
content: Wrap(
children: [
if (widget.suggestions != null)
Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
return widget.suggestions!.where((String option) {
return option.toLowerCase().contains(textEditingValue.text.toLowerCase());
});
},
onSelected: (String selection) {
setState(() {
headerName = selection;
});
},
fieldViewBuilder: (BuildContext context, TextEditingController textEditingController,
FocusNode focusNode, VoidCallback onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
minLines: 1,
maxLines: 3,
decoration: InputDecoration(labelText: localizations.headerName),
onChanged: (value) {
headerName = value;
setState(() {});
},
);
},
initialValue: TextEditingValue(text: headerName),
optionsViewBuilder:
(BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final String option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Container(
padding: const EdgeInsets.all(10.0),
child: _buildHighlightText(option, headerName),
),
);
},
),
),
),
);
},
)
else
TextFormField(
minLines: 1,
maxLines: 3,
initialValue: headerName,
decoration: InputDecoration(labelText: localizations.headerName),
onChanged: (value) {
headerName = value;
setState(() {});
},
),
if (HttpHeaders.commonHeaderValues.containsKey(headerName))
Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
return HttpHeaders.commonHeaderValues[headerName]!.where((String option) {
return option.toLowerCase().contains(textEditingValue.text.toLowerCase());
});
},
onSelected: (String selection) {
val = selection;
},
fieldViewBuilder: (BuildContext context, TextEditingController textEditingController,
FocusNode focusNode, VoidCallback onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
minLines: 1,
maxLines: 8,
decoration: InputDecoration(labelText: localizations.value),
onChanged: (value) => val = value,
);
},
initialValue: TextEditingValue(text: val),
optionsViewBuilder:
(BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final String option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Container(
padding: const EdgeInsets.all(10.0),
child: _buildHighlightText(option, val),
),
);
},
),
),
),
);
},
)
else
TextFormField(
minLines: 1,
maxLines: 8,
initialValue: val,
decoration: InputDecoration(labelText: localizations.value),
onChanged: (value) => val = value,
)
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)),
TextButton(
onPressed: () {
this.setState(() {
keyVal.key = headerName;
keyVal.value = val;
});
notifierChange();
Navigator.pop(ctx);
},
child: Text(localizations.modify)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)),
TextButton(
onPressed: () {
setState(() {
keyVal.key = headerName;
keyVal.value = val;
});
notifierChange();
Navigator.pop(ctx);
},
child: Text(localizations.modify)),
],
);
);
});
});
}
@@ -718,4 +850,23 @@ class KeyValState extends State<KeyValWidget> {
),
]);
}
Widget _buildHighlightText(String text, String query) {
if (query.isEmpty) {
return Text(text);
}
int index = text.toLowerCase().indexOf(query.toLowerCase());
if (index < 0) {
return Text(text);
}
return Text.rich(TextSpan(children: [
TextSpan(text: text.substring(0, index)),
TextSpan(
text: text.substring(index, index + query.length),
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)),
TextSpan(text: text.substring(index + query.length))
]));
}
}

View File

@@ -621,7 +621,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.proxy.pin;
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -762,7 +762,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.proxy.pin;
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -791,7 +791,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = 10.15;
MARKETING_VERSION = 1.1.8;
PRODUCT_BUNDLE_IDENTIFIER = com.proxy.pin;
PROVISIONING_PROFILE_SPECIFIER = "";