mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-05-20 16:15:47 +08:00
Add text and list display to HTTP headers
This commit is contained in:
@@ -93,7 +93,7 @@ class _JsonTextState extends State<JsonText> {
|
||||
chunks = chunks ?? splitTextSpans(textList, 500);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: MediaQuery.of(context).size.height - 160,
|
||||
height: MediaQuery.of(context).size.height - 200,
|
||||
child: SelectionArea(
|
||||
child: ScrollablePositionedList.builder(
|
||||
physics: Platforms.isDesktop() ? null : const BouncingScrollPhysics(),
|
||||
|
||||
@@ -82,6 +82,9 @@ class AppConfiguration {
|
||||
/// header默认展示
|
||||
bool headerExpanded = true;
|
||||
|
||||
/// Headers展示模式: table(逐行) / text(原始文本)
|
||||
String headerViewMode = "table";
|
||||
|
||||
/// 底部导航栏
|
||||
bool bottomNavigation = true;
|
||||
|
||||
@@ -206,6 +209,7 @@ class AppConfiguration {
|
||||
pipEnabled.value = config['pipEnabled'] ?? true;
|
||||
pipIcon.value = config['pipIcon'] ?? false;
|
||||
headerExpanded = config['headerExpanded'] ?? true;
|
||||
headerViewMode = config['headerViewMode'] ?? "table";
|
||||
bottomNavigation = config['bottomNavigation'] ?? true;
|
||||
memoryCleanupThreshold = config['memoryCleanupThreshold'];
|
||||
autoReadEnabled = config['autoReadEnabled'] ?? true;
|
||||
@@ -251,6 +255,7 @@ class AppConfiguration {
|
||||
"language": _language?.languageCode,
|
||||
"languageScript": _language?.scriptCode,
|
||||
"headerExpanded": headerExpanded,
|
||||
"headerViewMode": headerViewMode,
|
||||
"autoReadEnabled": autoReadEnabled,
|
||||
if (memoryCleanupThreshold != null) 'memoryCleanupThreshold': memoryCleanupThreshold,
|
||||
if (Platforms.isMobile()) 'pipEnabled': pipEnabled.value,
|
||||
|
||||
187
lib/ui/content/headers.dart
Normal file
187
lib/ui/content/headers.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright 2025 Hongen Wang All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
import 'package:flutter_highlight/themes/atom-one-dark.dart';
|
||||
import 'package:flutter_highlight/themes/atom-one-light.dart';
|
||||
import 'package:highlight/languages/http.dart';
|
||||
import 'package:proxypin/network/http/http.dart';
|
||||
import 'package:proxypin/ui/component/utils.dart';
|
||||
import 'package:proxypin/ui/configuration.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:proxypin/utils/platform.dart';
|
||||
|
||||
/// A reusable panel to display request/response headers.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// - table mode: each header shown as name/value rows
|
||||
/// - text mode: raw header lines in a read-only code field
|
||||
class HeadersWidget extends StatefulWidget {
|
||||
final String title;
|
||||
final HttpMessage? message;
|
||||
final TextStyle valueTextStyle;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
/// Optional shared controller for raw-text mode, so caller can reuse
|
||||
/// controllers between rebuilds (e.g. separate for Request/Response).
|
||||
final CodeController? controller;
|
||||
|
||||
const HeadersWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.valueTextStyle = const TextStyle(fontSize: 14),
|
||||
this.initiallyExpanded = true,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HeadersWidget> createState() => _HeadersWidgetState();
|
||||
}
|
||||
|
||||
class _HeadersWidgetState extends State<HeadersWidget> {
|
||||
// 静态缓存:按 title 区分的展开状态(保持同一进程内跨页面实例)
|
||||
static final Map<String, bool> _lastExpanded = {};
|
||||
late CodeController _controller;
|
||||
|
||||
// 当前实例展开状态
|
||||
late bool _expanded;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller =
|
||||
widget.controller ?? CodeController(readOnly: true, language: http, text: _buildRawHeaders(widget.message));
|
||||
// 优先使用按 type 缓存,其次使用全局配置,最后使用 widget 默认
|
||||
final key = widget.title;
|
||||
_expanded = _lastExpanded[key] ?? AppConfiguration.current?.headerExpanded ?? widget.initiallyExpanded;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildHeaderModeToggle(BuildContext context) {
|
||||
|
||||
final config = AppConfiguration.current;
|
||||
if (config == null) return const SizedBox();
|
||||
final isText = config.headerViewMode == 'text';
|
||||
void setMode(bool text) {
|
||||
config.headerViewMode = text ? 'text' : 'table';
|
||||
config.flushConfig();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
tooltip: isText ? 'Headers: Text' : 'Headers: Table',
|
||||
onPressed: () => setMode(!isText),
|
||||
icon: Icon(isText ? Icons.text_snippet : Icons.table_rows),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isTextMode = (AppConfiguration.current?.headerViewMode ?? 'table') == 'text';
|
||||
return ExpansionTile(
|
||||
tilePadding: const EdgeInsets.only(left: 0),
|
||||
dense: true,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child:
|
||||
Text('${widget.title} Headers', style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14))),
|
||||
_buildHeaderModeToggle(context),
|
||||
],
|
||||
),
|
||||
// 使用实例状态作为当前的展开状态
|
||||
initiallyExpanded: _expanded,
|
||||
onExpansionChanged: (expanded) {
|
||||
if (_expanded == expanded) return;
|
||||
_expanded = expanded;
|
||||
_lastExpanded[widget.title] = expanded;
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
shape: const Border(),
|
||||
children: !isTextMode ? _buildHeaderRows(widget.message) : buildTextMode(widget.message),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildTextMode(HttpMessage? message) {
|
||||
final text = _buildRawHeaders(message);
|
||||
if (_controller.text != text) {
|
||||
_controller = CodeController(readOnly: true, language: http, text: text);
|
||||
}
|
||||
|
||||
return [
|
||||
CodeTheme(
|
||||
data: CodeThemeData(
|
||||
styles: Theme.brightnessOf(context) == Brightness.light ? atomOneLightTheme : atomOneDarkTheme),
|
||||
child: CodeField(
|
||||
background: Colors.transparent,
|
||||
readOnly: Platforms.isMobile(),
|
||||
wrap: true,
|
||||
gutterStyle: const GutterStyle(margin: 0, width: 52, showErrors: false),
|
||||
textStyle: const TextStyle(fontSize: 15.3),
|
||||
controller: _controller,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildHeaderRows(HttpMessage? message) {
|
||||
final rows = <Widget>[];
|
||||
message?.headers.forEach((name, values) {
|
||||
for (final v in values) {
|
||||
rows.add(Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(name,
|
||||
contextMenuBuilder: contextMenu,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.deepOrangeAccent, fontSize: 15)),
|
||||
const Text(': ',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, color: Colors.deepOrangeAccent, fontSize: 15)),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
v,
|
||||
style: widget.valueTextStyle,
|
||||
contextMenuBuilder: contextMenu,
|
||||
maxLines: 8,
|
||||
minLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
rows.add(const Divider(thickness: 0.1, height: 10));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
String _buildRawHeaders(HttpMessage? message) {
|
||||
if (message == null) return '';
|
||||
final buffer = StringBuffer();
|
||||
message.headers.forEach((name, values) {
|
||||
for (final v in values) {
|
||||
buffer.writeln('$name: $v');
|
||||
}
|
||||
});
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,12 @@ import 'package:proxypin/network/bin/server.dart';
|
||||
import 'package:proxypin/network/http/http.dart';
|
||||
import 'package:proxypin/ui/component/state_component.dart';
|
||||
import 'package:proxypin/ui/component/utils.dart';
|
||||
import 'package:proxypin/ui/configuration.dart';
|
||||
import 'package:proxypin/ui/content/web_socket.dart';
|
||||
import 'package:proxypin/utils/lang.dart';
|
||||
import 'package:proxypin/utils/platform.dart';
|
||||
|
||||
import 'body.dart';
|
||||
import 'headers.dart';
|
||||
import 'menu.dart';
|
||||
|
||||
///网络请求详情页
|
||||
@@ -219,35 +219,13 @@ class NetworkTabState extends State<NetworkTabController> with SingleTickerProvi
|
||||
}
|
||||
|
||||
List<Widget> message(HttpMessage? message, String type, ScrollController scrollController) {
|
||||
var headers = <Widget>[];
|
||||
message?.headers.forEach((name, values) {
|
||||
for (var v in values) {
|
||||
const nameStyle = TextStyle(fontWeight: FontWeight.w500, color: Colors.deepOrangeAccent, fontSize: 14);
|
||||
headers.add(Row(children: [
|
||||
SelectableText(name, contextMenuBuilder: contextMenu, style: nameStyle),
|
||||
const Text(": ", style: nameStyle),
|
||||
if (Platforms.isDesktop()) SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: SelectableText(v, style: textStyle, contextMenuBuilder: contextMenu, maxLines: 8, minLines: 1)),
|
||||
]));
|
||||
headers.add(const Divider(thickness: 0.1));
|
||||
}
|
||||
});
|
||||
|
||||
Widget bodyWidgets = HttpBodyWidget(
|
||||
key: type == "Request" ? requestHttpBodyKey : responseHttpBodyKey,
|
||||
hideRequestRewrite: widget.windowId != null,
|
||||
httpMessage: message,
|
||||
scrollController: scrollController);
|
||||
|
||||
Widget headerWidget = ExpansionTile(
|
||||
tilePadding: const EdgeInsets.only(left: 0),
|
||||
title: Text("$type Headers", style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14)),
|
||||
initiallyExpanded: AppConfiguration.current?.headerExpanded ?? true,
|
||||
shape: const Border(),
|
||||
children: headers);
|
||||
|
||||
return [headerWidget, bodyWidgets];
|
||||
return [HeadersWidget(title: type, message: message, valueTextStyle: textStyle), bodyWidgets];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,32 +260,32 @@ class General extends StatelessWidget {
|
||||
var content = [
|
||||
const SizedBox(height: 10),
|
||||
RowWidget("Request URL", requestUrl),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Request Method", request.method.name),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Protocol", request.protocolVersion),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Status Code", response?.status.toString()),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Remote Address",
|
||||
'${response?.remoteHost ?? ''}${response?.remotePort == null ? '' : ':${response?.remotePort}'}'),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Request Time", request.requestTime.formatMillisecond()),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Duration", response?.costTime()),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Request Content-Type", request.headers.contentType),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Response Content-Type", response?.headers.contentType),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Request Package", getPackage(request.packageSize)),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
RowWidget("Response Package", getPackage(response?.packageSize)),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
];
|
||||
if (request.processInfo != null) {
|
||||
content.add(RowWidget("App", request.processInfo!.name));
|
||||
content.add(const SizedBox(height: 20));
|
||||
content.add(const SizedBox(height: 15));
|
||||
}
|
||||
|
||||
return ListView(children: [expansionTile("General", content)]);
|
||||
@@ -328,7 +306,7 @@ class Cookies extends StatelessWidget {
|
||||
var responseCookie = response.get()?.headers.getList("Set-Cookie")?.expand((e) => _cookieWidget(e)!);
|
||||
return ListView(children: [
|
||||
requestCookie == null ? const SizedBox() : expansionTile("Request Cookies", requestCookie.toList()),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
responseCookie == null ? const SizedBox() : expansionTile("Response Cookies", responseCookie.toList()),
|
||||
]);
|
||||
}
|
||||
@@ -338,7 +316,7 @@ class Cookies extends StatelessWidget {
|
||||
|
||||
cookie?.split(";").map((e) => Strings.splitFirst(e, "=")).where((element) => element != null).forEach((e) {
|
||||
headers.add(RowWidget(e!.key.trim(), e.value));
|
||||
headers.add(const Divider(thickness: 0.1));
|
||||
headers.add(const Divider(thickness: 0.1, height: 10));
|
||||
});
|
||||
|
||||
return headers;
|
||||
|
||||
@@ -76,7 +76,7 @@ class _SettingState extends State<Setting> {
|
||||
item(localizations.requestRewrite, onPressed: requestRewrite),
|
||||
item(localizations.requestMap, onPressed: requestMap),
|
||||
item(localizations.script,
|
||||
onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 700))),
|
||||
onPressed: () => MultiWindow.openWindow(localizations.script, 'ScriptWidget', size: const Size(800, 730))),
|
||||
item(localizations.externalProxy, onPressed: setExternalProxy),
|
||||
item(localizations.about, onPressed: showAbout),
|
||||
],
|
||||
|
||||
@@ -100,17 +100,18 @@ class _AboutState extends State<About> {
|
||||
trailing: const Icon(Icons.privacy_tip_outlined, size: 22),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 385),
|
||||
child: AlertDialog(
|
||||
title: Text(localizations.privacyPolicy),
|
||||
content: SingleChildScrollView(child: Text(localizations.privacyContent)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close))
|
||||
],
|
||||
),
|
||||
));
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(localizations.privacyPolicy),
|
||||
content: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 385),
|
||||
child: Text(localizations.privacyContent, style: const TextStyle(height: 1.35)))),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(localizations.close))
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
Divider(height: 0, thickness: 0.4, color: Theme.of(context).dividerColor.withValues(alpha: 0.22)),
|
||||
// Sponsor / Donate entry
|
||||
|
||||
Reference in New Issue
Block a user