Add text and list display to HTTP headers

This commit is contained in:
wanghongenpin
2025-12-30 22:02:52 +08:00
parent e98229c877
commit ce6ee05de4
7 changed files with 225 additions and 53 deletions

View File

@@ -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(),

View File

@@ -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
View 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();
}
}

View File

@@ -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;

View File

@@ -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),
],

View File

@@ -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