docs: explain shell activity and platform support boundaries

This commit is contained in:
2026-11-12 19:10:08 +08:00
parent c28c08a29a
commit 50571e156e
6 changed files with 50 additions and 1 deletions
+17
View File
@@ -18,15 +18,22 @@ const MAX_RECORDS: usize = 80;
#[derive(Clone, Debug)]
/// 一次 HTTP 请求/响应的有界观察记录,供 Network 工具查看。
pub struct Exchange {
// 由 begin 分配的日志身份,HTTP worker 后续只携带此 ID 更新,不持有队列元素引用。
pub id: u64,
// 请求方法和来源标签由本地 API 观察器传入;origin 只供显示,不授予执行权限。
pub method: String,
// 请求 URL 的受限预览,不在此解析路由或读取所指文件。
pub path: String,
pub origin: String,
// 开始时刻的 UTC 时分秒文本,不含日期;耗时另用 started 单调时钟计算。
pub timestamp: String,
// 初始为空,成功读取请求体后 body 覆盖预览;不是用于执行脚本的原始正文存储。
pub request: String,
// finish 记录已构造响应的正文预览,即使后续 socket 发送失败也可能有值。
pub response: String,
// None 表示尚未记录响应;完成后保存 HTTP 状态码,不据此判断业务调用是否成功。
pub status: Option<u16>,
// 初始为零,finish 时从 begin 起累计毫秒;不在界面每次绘制时持续刷新。
pub elapsed_ms: u128,
pub request_headers: String,
pub response_headers: String,
@@ -37,12 +44,15 @@ pub struct Exchange {
#[derive(Debug, Default)]
/// 按容量维护近期 HTTP 活动与请求身份,防止无限日志积累。
pub struct ActivityLog {
// 开始顺序队列,Network/Console 工具通过共享锁读取或复制;完成顺序不会重排记录。
pub records: VecDeque<Exchange>,
next_id: u64,
}
impl ActivityLog {
/// 为请求分配活动 ID 并登记其方法、来源和受限头部。
// runtime/api 的 ObservedRequest 构造时调用;先写日志再由调用方通知界面,本模块不主动唤醒窗口。
pub fn begin(&mut self, method: String, path: String, origin: String, headers: String) -> u64 {
// ID 在当前日志实例内递增,不从队列长度推导;淘汰旧项不会重置计数。
self.next_id += 1;
let id = self.next_id;
// 显示时间取 Unix 秒数的当日余数;系统时间早于纪元时回退到零。这里只显示时间,不保存日期。
@@ -51,6 +61,7 @@ impl ActivityLog {
.unwrap_or_default()
.as_secs()
% 86400;
// 登记开始状态,正文和响应留空,started 与墙上时钟分别服务耗时和时间显示。
self.records.push_back(Exchange {
id,
method,
@@ -78,13 +89,16 @@ impl ActivityLog {
}
/// 更新请求正文预览,内容长度仍由活动记录策略限制。
// 线性查找在最多 80 项范围内完成;本方法覆盖之前的请求预览,不拼接分块正文。
// ObservedRequest::record_body 在读取请求体后调用,后续 submit 仍使用原正文,日志只保存截断副本。
pub fn body(&mut self, id: u64, body: &str) {
// 按 ID 更新仍在队列内的记录;请求被容量淘汰时不重建,以免旧请求挤走较新的观察记录。
if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
record.request = preview(body);
}
}
/// 为指定请求登记响应状态、正文/头部预览与单调时钟耗时;记录已淘汰时忽略。
// 仅更新仍保留的请求,响应正文和头部都重新截断;耗时从 begin 起计算。
// ObservedRequest::respond 在真实响应发送前调用,状态和耗时表示已构造响应,不证明客户端接收成功。
pub fn finish(&mut self, id: u64, status: u16, body: &str, headers: String) {
if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
record.status = Some(status);
@@ -96,12 +110,15 @@ impl ActivityLog {
}
/// 截取可显示的受限文本预览,避免日志无限保留完整大正文。
// begin/body/finish 统一使用 UTF-8 字节预算;输入未超限时仍返回独立 String,不借用请求缓冲。
pub fn preview(value: &str) -> String {
// 恰好等于上限也保留原文,不追加截断标记;长度判断按字节而不是字符数量。
if value.len() <= BODY_PREVIEW {
return value.to_owned();
}
// UTF-8 多字节字符不能从中间切开;从字节上限回退到合法边界,确保输出始终是有效字符串。
let mut end = BODY_PREVIEW;
// 最多回退到当前 UTF-8 字符起点,只保证字符边界,不承诺完整字素簇边界。
while !value.is_char_boundary(end) {
end -= 1;
}
+9
View File
@@ -5,6 +5,7 @@
use anyhow::{Result, bail};
#[cfg(target_os = "macos")]
/// 构造固定 UTF-8 环境的系统命令;本函数不启动进程,退出状态由 read/write 检查。
// 仅 macOS 的 read/write 使用,构造独立 Command 并覆盖编码环境,不修改宿主进程全局环境变量。
fn command(program: &str) -> std::process::Command {
let mut cmd = std::process::Command::new(program);
// 从 Finder 等无 LANG 的环境启动时也显式指定 UTF-8,防止系统工具按其他编码转换中文。
@@ -14,6 +15,7 @@ fn command(program: &str) -> std::process::Command {
cmd
}
/// 取得系统剪贴板的 UTF-8 文本,失败作为可显示错误返回。
// shell/app 处理开发工具粘贴快捷键时调用;失败由工具 input_error 展示,不用空字符串冒充成功。
pub fn read() -> Result<String> {
#[cfg(target_os = "macos")]
{
@@ -22,18 +24,22 @@ pub fn read() -> Result<String> {
.arg("-Prefer")
.arg("txt")
.output()?;
// 进程正常退出但状态非成功仍为错误,不能把可能残缺的 stdout 当作源代码粘贴。
if !output.status.success() {
bail!("system clipboard read failed");
}
// Never silently replace invalid bytes in the user's source.
// 严格验证输出 UTF-8,非法字节报错而不是替换为 U+FFFD 后悄悄改变用户文本。
Ok(String::from_utf8(output.stdout)?)
}
// 平台分支在编译时选择,非 macOS 不尝试猜测其他系统剪贴板命令。
#[cfg(not(target_os = "macos"))]
{
bail!("system clipboard adapter is not available on this platform")
}
}
/// 将提供的文本交给系统剪贴板命令,不执行文本内容。
// shell/app 的复制与剪切路径调用;只有成功返回后剪切才会删除编辑器中的选中文本。
pub fn write(value: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
@@ -42,13 +48,16 @@ pub fn write(value: &str) -> Result<()> {
let mut child = command("/usr/bin/pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()?;
// 取走子进程管道句柄,后面可显式 drop 发送 EOF,不能让 pbcopy 一直等待更多输入。
let mut stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("clipboard stdin is unavailable"))?;
// 按 UTF-8 原字节写完整内容;写管道失败立即传播,此函数该错误路径没有另外执行 wait。
stdin.write_all(value.as_bytes())?;
// 写完后先关闭管道发送 EOF,让 pbcopy 知道输入结束;之后等待退出状态,不能仅以写管道成功作为完成。
drop(stdin);
// 等待真实系统工具退出并检查状态;写入成功和剪贴板操作完成是两个独立检查。
if !child.wait()?.success() {
bail!("system clipboard write failed");
}
+2 -1
View File
@@ -1,6 +1,7 @@
//! 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
// app 在原生事件循环中协调这些状态,并通过共享数据和输入协议与 runtime 衔接;本文件只声明模块,不启动线程或窗口。
/// 保存运行活动、请求和诊断记录,供宿主开发工具展示。
pub(crate) mod activity;
/// 实现 winit 应用生命周期、窗口协调、输入路由和 softbuffer 像素呈现。
@@ -14,7 +15,7 @@ pub(crate) mod developer_tools;
pub(crate) mod native_chrome;
/// 维护页面/工具入口列表、折叠宽度和命中目标;可用页面宽度需扣除其占用。
pub(crate) mod sidebar;
/// 从源码与诊断生成可浏览的结构信息,供 Sources 工具定位对象和文本。
/// 定义云文件 provider 和 Sources 工作区状态,供工具列出文件并只读打开内容。
pub(crate) mod sources;
/// 提供宿主管理界面专用的主题、控件和交互状态,和 Endor 业务界面共享底层绘图能力。
pub(crate) mod system_ui;
+11
View File
@@ -12,6 +12,7 @@ use winit::{
/// The custom draggable row does not receive AppKit's titlebar double-click
/// action automatically. Use the native click count and native window actions.
/// 读取系统偏好的标题栏双击行为,委托原生窗口执行相应动作。
// shell/app 在自绘标题控制区左键按下时调用;true 表示本次双击已处理,调用方不再开始普通窗口拖动。
pub fn titlebar_double_click(window: &Window) -> bool {
// AppKit 必须在主线程调用;不在主线程、无有效句柄或句柄非 AppKit 时返回未处理。
let Some(main_thread) = MainThreadMarker::new() else {
@@ -26,6 +27,7 @@ pub fn titlebar_double_click(window: &Window) -> bool {
// SAFETY: called on the event-loop thread during a left-button press, with
// a live winit-owned NSView. No native object outlives the Window borrow.
unsafe {
// 从当前 AppKit 事件读取原生 clickCount;无事件就不猜测双击,也不执行窗口动作。
let Some(event) = NSApplication::sharedApplication(main_thread).currentEvent() else {
return false;
};
@@ -33,6 +35,7 @@ pub fn titlebar_double_click(window: &Window) -> bool {
if event.clickCount() != 2 {
return false;
}
// 借用 winit 提供的活动 NSView,再取得所属 NSWindow;不创建窗口或把原生指针保存到外部状态。
let view = &*handle.ns_view.as_ptr().cast::<NSView>();
let Some(native) = view.window() else {
return false;
@@ -41,6 +44,7 @@ pub fn titlebar_double_click(window: &Window) -> bool {
if native.styleMask().contains(NSWindowStyleMask::FullScreen) {
return true;
}
// 读取系统标题栏双击设置,字符串转成 Rust 自有值后按约定选原生动作。
let action = NSUserDefaults::standardUserDefaults()
.stringForKey(&NSString::from_str("AppleActionOnDoubleClick"))
.map(|value| value.to_string());
@@ -58,7 +62,9 @@ pub fn titlebar_double_click(window: &Window) -> bool {
/// Called only on winit's main thread, after native creation and before drawing.
/// AppKit can restore its default button positions when laying out the title bar.
/// 调整真实 macOS 窗口三键的位置,与全尺寸内容和侧栏控制区对齐。
// shell/app 创建窗口后和绘制前调用;主线程调用由外层保证,此函数本身没有创建 MainThreadMarker 检查。
pub fn align_controls(window: &Window) {
// 取得当前 Window 的句柄,失败或非 AppKit 平台表示无法调整,返回不改变现有原生布局。
let Ok(handle) = window.window_handle() else {
return;
};
@@ -76,6 +82,7 @@ pub fn align_controls(window: &Window) {
if native.styleMask().contains(NSWindowStyleMask::FullScreen) {
return;
}
// 读取内容视图边界,用与侧栏相同的 HEADER 高度确定标题行中心。
let bounds = view.bounds();
let center = f64::from(crate::sidebar::HEADER) / 2.0;
// AppKit 视图可能以左下或左上为原点;先在内容视图内算标题行中心,再转到按钮父视图。
@@ -84,19 +91,23 @@ pub fn align_controls(window: &Window) {
} else {
bounds.origin.y + bounds.size.height - center
};
// 只处理关闭、最小化和缩放三个真实系统按钮,不创建伪按钮代替原生交互。
for kind in [
NSWindowButton::NSWindowCloseButton,
NSWindowButton::NSWindowMiniaturizeButton,
NSWindowButton::NSWindowZoomButton,
] {
// 系统样式可能不提供某个按钮,缺失时跳过该项,其他按钮仍继续对齐。
let Some(button) = native.standardWindowButton(kind) else {
continue;
};
// 需要按钮父视图做坐标转换;没有父视图时不能直接写内容视图坐标。
let Some(parent) = button.superview() else {
continue;
};
// 按钮可能位于不同 AppKit 容器,必须使用 convertPoint 而不能直接赋内容视图坐标。
let target = parent.convertPoint_fromView(NSPoint::new(0.0, content_y), Some(view));
// 保留当前大小与横坐标,仅把目标中心减半高换算成按钮纵向原点。
let frame = button.frame();
let y = target.y - frame.size.height / 2.0;
// Preserve native button size, horizontal spacing and behavior.
+9
View File
@@ -16,7 +16,9 @@ pub struct SourceFile {
#[derive(Clone, Debug)]
/// 已经取得的源文件内容与标识,不代表它已经被执行。
pub struct OpenedSource {
// provider 返回的展示路径,Sources 面板据此显示当前打开文件标题,不把它当作执行入口。
pub path: String,
// 已读取的文本副本,仅绘制源码行;不覆盖当前已发布文档的 source 或触发热重载。
pub code: String,
}
@@ -33,12 +35,16 @@ pub trait CloudSourceProvider: std::fmt::Debug + Send {
pub struct SourceWorkspace {
// None 表示未接入外部文件服务;默认工作区不提供虚构文件或隐式认证。
pub provider: Option<Box<dyn CloudSourceProvider>>,
// 最近一次 list_files 成功返回的完整列表,刷新失败仍保留供界面查看。
pub files: Vec<SourceFile>,
// 最近成功打开的内容;选择另一个文件失败时保持旧内容,不显示半读取结果。
pub opened: Option<OpenedSource>,
// 最近调用错误的可显示文本,成功 refresh/open 会清空;默认未连接时初始为空。
pub error: String,
}
impl SourceWorkspace {
/// 更新云文件列表并保存结果或错误,供 Sources 页面显示。
// developer_tools 处理 Action::CloudRefresh 时调用,build 仅根据保存状态绘制,不在绘制中请求 provider。
pub fn refresh(&mut self) {
// 未连接时明确写错误并返回;已有文件列表/打开内容不会在失败路径中被清除。
let Some(provider) = &mut self.provider else {
@@ -47,6 +53,7 @@ impl SourceWorkspace {
};
// 调用是同步的,调用方负责安排执行位置;成功才整体替换列表,失败保留旧列表供查看。
match provider.list_files() {
// 只替换列表并清错,不自动重读或关闭已经打开的文件。
Ok(files) => {
self.files = files;
self.error.clear();
@@ -56,12 +63,14 @@ impl SourceWorkspace {
}
/// 更新选中文件的内容与打开状态,失败保持可读错误信息。
// 只读取内容而不执行代码;未连接时直接返回,不像 refresh 那样写入新的错误。
// developer_tools 处理 Action::CloudOpen(id) 时调用,id 来自 SourceFile,不用显示路径代替 provider 身份。
pub fn open(&mut self, id: &str) {
let Some(provider) = &mut self.provider else {
return;
};
// 成功替换 opened 并清错;失败只更新 error,用户仍可查看之前成功打开的内容。
match provider.open_file(id) {
// provider 返回完整内容后一次替换 opened,查看动作不会执行其中的 Endor 程序。
Ok(source) => {
self.opened = Some(source);
self.error.clear();
+2
View File
@@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// 托管页面注册表的不可变投影,原生工具只使用稳定 ID 与显示元数据。
// ui_bridge::project_tabs 从当前窗口页面注册表生成;sidebar 画标签,scene 用 developer_tool 标记选择宿主管理页。
pub struct Tab {
// 会话内稳定 ID,供原生侧发起切页/关闭操作;不能直接当作 VM 对象地址。
pub id: u64,
// 本次发布的标题文本副本,原生界面显示时无需读取 VM。
pub title: String,
// 该标签是否是此窗口当前活动页;页面注册表顺序与选中状态分别表示。
// 投影可反映待提交切页请求,不能仅凭这一字段推断新页面布局已经发布成功。
pub active: bool,
// 开发工具页的类别标识;普通页省略此字段,反序列化缺失字段时默认为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]