Files
endorphin-tessera-gui/src/shell/developer_tools/network.rs
T

145 lines
6.1 KiB
Rust

//! 绘制已观察到的 HTTP 活动、请求列表与详情。
//! 筛选、选择和滚动是工具视图状态;展示一条请求不会重发该请求。
//!
use super::*;
/// 将本进程 HTTP 活动投影为可筛选请求列表和独立滚动详情,并生成选择/分隔条命中区域。
// developer_tools::build 选择 Network 时调用,共享日志已在父入口克隆,过滤和文字测量不持活动日志锁。
pub(super) fn build(
p: &mut Painter,
state: &mut DeveloperToolsState,
records: &[Exchange],
viewport: Rect,
) {
// 工具页以 viewport 横向原点和宽度布局;底部状态栏高度留给父入口统一绘制。
let x = viewport.x;
let w = viewport.width;
let bottom = viewport.height - 22.0;
p.fill(Rect::new(x, 32.0, w, 30.0), BAR);
// 水位后存在记录才允许 Clear,不受当前筛选是否有匹配影响;动作更新水位并清除选择,不删除共享日志。
p.enabled_button(
Rect::new(x + 3.0, 32.0, 62.0, 29.0),
"Clear",
Action::ClearNetwork,
records.iter().any(|r| r.id > state.network_after),
);
// 过滤框注册 Filter 焦点,按键编辑在 DeveloperToolsState 中处理;此处只展示当前文本与焦点。
let filter_area = Rect::new(x + 72.0, 35.0, (w - 85.0).max(100.0), 24.0);
p.ui.text_field(
filter_area,
Action::Focus(Focus::Filter),
state.focus == Some(Focus::Filter),
);
p.label(
filter_area.x + 7.0,
filter_area.y + 3.0,
&format!(
"Filter: {}{}",
state.filter,
// 竖线是焦点提示字符,不是 Console 那种按字节光标定位,也不修改过滤字符串本身。
if state.focus == Some(Focus::Filter) {
"│"
} else {
""
}
),
MUTED,
false,
);
p.label(
x + 10.0,
69.0,
"METHOD STATUS TIME PATH",
MUTED,
true,
);
// 上下区分隔位置取自工具状态比例,拖动只改变显示分配,不改变记录本身。
let divider = 92.0 + (bottom - 92.0) * state.network_fraction;
// 对方法、路径和状态的组合字符串做大小写不敏感子串匹配;未响应记录可用 pending 搜索。
let filter = state.filter.to_lowercase();
// 保留原记录顺序及引用,只建立本次展示候选,不复制/更改每条 Exchange 的响应状态。
let visible = records
.iter()
// 清屏按 ID 水位隐藏旧记录,不删除共享 ActivityLog,Console 仍可独立展示同一条请求。
.filter(|r| r.id > state.network_after)
// 方法、路径与状态合并后统一小写匹配,origin 和正文不在当前过滤范围内。
.filter(|r| {
format!(
"{} {} {}",
r.method,
r.path,
r.status.map_or("pending".into(), |s| s.to_string())
)
.to_lowercase()
.contains(&filter)
})
.collect::<Vec<_>>();
let mut lines = Vec::new();
// 逐匹配记录生成一行,pending 状态用省略号;耗时直接取日志快照,不在绘制时重新计时。
for r in &visible {
// 先创建带状态颜色的行,再附加稳定 Request 动作和选中标记,筛选改变行号也不会改变目标身份。
let mut l = Line::new(
format!(
"{:<7} {:<6} {:>5} ms {}",
r.method,
r.status.map_or("…".into(), |s| s.to_string()),
r.elapsed_ms,
r.path
),
if r.status.is_some_and(|s| s >= 400) {
ERROR
} else {
TEXT
},
);
// 请求 ID 是选择身份,不依赖筛选后行号;点击行仅生成 Request 动作,不重新发送 HTTP。
l.selected = state.selected_request == Some(r.id);
l.action = Some(Action::Request(r.id));
lines.push(l);
}
// 空列表可能来自无记录、清屏或过滤无匹配,统一展示本进程 API 的捕获范围提示。
if lines.is_empty() {
lines.push(Line::new(
"No matching requests. Listening to Tessera's local API.",
MUTED,
));
}
// 列表使用 Requests Region 保存独立滚动,false 表示不因为新增请求自动跟随到底部。
p.lines(
Rect::new(x + 1.0, 92.0, w - 2.0, divider - 92.0),
Region::Requests,
&lines,
state,
false,
);
// 只登记横向分隔条,父绘制入口最后覆盖绘制并注册 Split::Network 命中目标。
p.split(Rect::new(x + 1.0, divider, w - 2.0, 1.0), Split::Network);
// 详情从完整保留记录中查找,不复用筛选结果或 network_after;筛选隐藏行后仍可查看。
// 正常 ClearNetwork 会另行清 selected_request,记录被容量淘汰后此处也找不到,才回到提示。
let selected = records
.iter()
.find(|r| Some(r.id) == state.selected_request);
// 详情另建行列表,不依赖上面的 visible;筛选无匹配并不自动取消已选请求。
let mut details = Vec::new();
// 仍保留的已选记录交给 request_details;ID 已淘汰或从未选择则显示捕获范围说明。
if let Some(r) = selected {
request_details(&mut details, r);
} else {
multiline(
&mut details,
"Select a request to inspect headers and bodies.\n\nCapture scope: 127.0.0.1:4737 in this Tessera process.\n80 most recent requests; body previews up to 32 KiB.\nOnly Content-Type and Content-Length request headers are recorded.\nExternal browser / OS traffic is not captured.",
MUTED,
);
}
// 请求列表与详情使用不同 Region,各自维护滚动量,避免切换详情影响列表定位。
p.lines(
Rect::new(x + 1.0, divider + 5.0, w - 2.0, bottom - divider - 5.0),
Region::Details,
&details,
state,
false,
);
}