1080 Commits

Author SHA1 Message Date
xfy
e18d2bfb2c fix(docker): 用 tmpfs mode=1777 替换 uid/gid 选项以兼容 Podman 2026-07-14 16:42:04 +08:00
xfy
af3cd4b525 chore: gitignore public/xterm 构建产物
新增 xterm feature 时漏了同步 .gitignore,导致 xterm-terminal 的
vite 构建产物(terminal.{js,css,js.map})被提交进仓库——与其余
4 个 lib(tiptap/codemirror/lightbox/yggdrasil-core)一致,这些
IIFE 产物本就该忽略,源码在 libs/xterm-terminal/src/。

git rm --cached 三个产物文件(磁盘保留),.gitignore 补 public/xterm。
2026-07-14 16:36:51 +08:00
xfy
9bc8b5c2ee refactor(libs): 抽取 @yggdrasil/shared 消除跨 IIFE 库的类型/常量重复
新增 libs/shared 内部包,作为跨 IIFE 库的单一真相源:
- ThemeName 类型(原 codemirror/themes.ts + xterm/themes.ts 各一份)
- THEME_CHANGE_EVENT 常量(原 yggdrasil-core 导出 + codemirror/xterm
  各硬编码同名 string literal,靠注释维系一致)
- prefersReducedMotion 函数(原 lightbox + yggdrasil-core 各一份)

codemirror-editor / xterm-terminal / lightbox / yggdrasil-core 四个库
改为 workspace 依赖 @yggdrasil/shared,Vite 构建时 inline 进各自 IIFE,
不破坏 IIFE 隔离。消除「改一处忘改另一处导致静默失效」的风险。

附:image_reader_limits 已在上一提交共享;本提交是 libs 侧的对称改动。
2026-07-14 15:39:41 +08:00
xfy
6837b4cfa2 refactor(image): 合并维度读取函数,共享 image_reader_limits
提取 read_webp_dimensions / read_image_dimensions 两个核心函数,
read_dimensions_by_mime 与 read_dimensions_from_bytes 各自只负责
格式映射(MIME→format / 扩展名→format),消除 zenwebp 调用与
into_dimensions 逻辑的双份实现。

附带收紧:read_dimensions_from_bytes 原用 with_guessed_format() 猜测
格式,现改为按扩展名显式匹配,避免猜测错误。

image_reader_limits() 改为 pub(crate),upload.rs 的内联 limits 构造
(4 行逐字重复)改为调用共享函数。
2026-07-14 15:13:21 +08:00
xfy
fade4e180f refactor: 删除死代码 CommentActions 组件
components/comments/actions.rs (CommentActions) 零引用——评论管理
操作(通过/垃圾/删除)已在 pages/admin/comments.rs 用 BTN_SOLID_*
常量实现,actions.rs 是遗留的重复实现。

原计划将它的内联按钮样式提取为 BTN_PILL_* 常量统一,但确认零引用后
直接删除整个文件更干净。trash_comment re-export 补 allow(unused_imports)
(wasm-only import 在 server 构建触发的已知模式)。
2026-07-14 15:09:41 +08:00
xfy
9e57247a2d refactor: 统一 WASM sleep 到 utils::time::sleep_ms
删除 system.rs 的私有 wasm_sleep(与 utils::time::sleep_ms 逐字相同),
6 处调用改用 crate::utils::time::sleep_ms。
ui.rs 的内联 Promise+setTimeout(50ms) 同样替换为 sleep_ms(50)。

消除 3 份 sleep 实现中的 2 份冗余副本,utils::time::sleep_ms 成为全项目
唯一的 sleep 入口(已有 native tokio + WASM 双实现)。
2026-07-14 14:55:34 +08:00
xfy
804bcfb7b7 refactor(upload): 抽取 upload_error() 消除 15 处 JSON 错误响应样板
upload.rs 中 14 处 return Err((StatusCode::X, Json(json!({"success":
false, "error": MSG})))) + 1 处 map_err 同款 tuple,全部替换为
upload_error(status, msg) 单行调用。helper 接受 impl Serialize,
兼容 &str / String / 字面量等调用形式。
2026-07-14 14:53:18 +08:00
xfy
8da678b3b4 refactor(cache): 抽取 invalidate_post_metadata() 封装 4 行失效组合
文章写操作后总要一起失效 lists/tags/stats/search 四项元数据缓存。
新增 invalidate_post_metadata() 封装这个固定组合,替换 create/delete/
update/trash 中的 8 处重复 4 行序列。

保留定向失效(invalidate_post_by_slug / invalidate_tag_posts_for)
和批量回退路径(invalidate_all_post_caches + invalidate_search_results)
的显式调用——它们是按实际涉及数据细粒度控制的,不应被封装吞掉。
2026-07-14 14:44:00 +08:00
xfy
22750ef89e refactor(api): 为 Response 类型添加构造器,消除 51 处样板
CreatePostResponse 新增 err()/ok()/ok_msg() 构造器,消除 posts/
 下 34 处 { success, message, post_id, slug } 字面量构造。

CommentResponse 新增 error()/ok()/created() 构造器,消除 comments/
 下 17 处 { success, message, error_code, comment_id, ... } 字面量构造。

构造器只在 server function body 内调用,WASM 端因 cfg gate 剥离了
调用点,故加 #[cfg_attr(not(feature="server"), allow(dead_code))]。

行为完全不变(505 测试全过),代码量净减约 300 行样板。
2026-07-14 14:36:56 +08:00
xfy
16caa7dd9e fix(comments): 统一 escape_html,修复代码块单引号未转义
comments/markdown.rs 的本地 html_escape 只转义 4 个字符(漏单引号),
与规范实现 utils::html::escape_html(5 字符,'→')不一致。

删除本地副本,改用 crate::utils::html::escape_html。评论代码块里的
单引号现在会被正确转义,与其他上下文的转义行为统一。
2026-07-14 14:29:53 +08:00
xfy
b39afbb616 refactor: 删除死代码 (delayed_loading.rs / ui.rs EmptyState / 未用 re-export)
- 删除 src/hooks/delayed_loading.rs:未在 hooks/mod.rs 注册,已被
  DelayedSkeleton 组件取代(mod.rs 注释已说明)
- 删除 src/components/ui.rs 的 EmptyState:零调用者,所有实际用法
  都走功能更全的 components/empty_state.rs::EmptyState
- 删除 api/posts/mod.rs 末尾的 render_markdown_enhanced / slug 工具
  re-export:调用方均使用完整路径 crate::api::{markdown,slug}::*,
  这两个跨模块短路径无引用
- 删除 api/comments/mod.rs 的 render_comment_markdown re-export:
  调用方用完整路径 crate::api::comments::markdown::render_comment_markdown

保留 auth.rs::invalidate_user_sessions:有完整文档说明的预留基础设施
(角色变更/封禁场景),非遗留垃圾。
2026-07-14 14:27:31 +08:00
xfy
0e4109358f docs(system): 修复 BackupRowProps 文档里 Popover 的 broken intra-doc link
Some checks failed
CI / check (push) Failing after 11m58s
CI / build (push) Has been skipped
[`Popover`] 简写形式解析失败——Popover 的 use 只在 fn BackupRow 函数体里,
对 BackupRowProps struct 的 doc comment 不可见。补全为全路径
[`Popover`](crate::components::ui::Popover),保留可点击跳转。
2026-07-14 13:39:34 +08:00
xfy
eccaa9f2a9 chore: release v0.4.0
Code Runner + /admin/system + UI 重新设计
v0.4.0
2026-07-13 19:59:22 +08:00
xfy
c3ae0aa975 refactor(skills): deploy-to-xun → deploy-to-linux 通用化
将部署 skill 从 xun 专属改为适用于任意 Linux 服务器:
- 新增「第 0 步:探测目标服务器」:一条 ssh 命令拿全 OS/运行时/shell/
  socket/磁盘/端口/容器列表,配套决策表
- 新增「Docker vs Podman 关键差异」表(socket 路径/镜像短名/tmpfs 兼容)
- 前置反代从「复用 nginx-proxy」单一选项扩展为三方案决策:
  A 复用现有 nginx-proxy / B 自建 nginx-proxy / C 自建 nginx + 手动证书
- compose 模板参数化:<host>/<域名>,socket 挂载按 Docker/Podman 二选一
- 保留项目专属部分(LANGUAGES 注册表硬编码、runner 沙箱、镜像名约定)
- fish shell 陷阱、Rosetta 要求、buildx 命名 bug、scratch 健康检查等
  实战经验全部保留
2026-07-13 19:47:56 +08:00
xfy
7584e0ca1c docs(skills): 新增 deploy-to-xun 部署流程 skill
将本次实战验证的部署流程提取为可复用 skill,涵盖:
- 本地 arm64 用 Docker Rosetta 构建 x86 镜像(绕过 QEMU 跑 rustc 的 SIGSEGV)
- runner 镜像 localhost/ 前缀与 LANGUAGES 注册表硬编码名的 tag 映射
- xun 服务器 fish shell 陷阱(变量赋值/循环/退出码语法差异)
- 复用 nginx-proxy + acme-companion 自动签证的 compose 配置
- podman socket 路径映射与 scratch 镜像健康检查方式
- Code Runner 在 podman 上的 tmpfs 兼容性已知限制

每一步均在本次会话中实战验证通过(14 迁移成功、HTTPS 200、证书签发)。
2026-07-13 19:40:41 +08:00
xfy
067075df8a fix(post): 直接访问 #hash 时标题被 sticky header 遮住
点击锚点正常,但直接访问带 hashtag 的链接时滚动后标题被 80px sticky
header 遮住。根因:浏览器原生 fragment-scroll(直接访问触发)不读 CSS
scroll-margin-top,在我们 scrollToHash 的平滑滚动之后触发,把标题顶到
视口最顶端(header 下方)。

抽出共享 scrollToHeading:用 getBoundingClientRect 手动计算绝对滚动位置,
减去动态测量的 header 高度(+16px 呼吸空间)后 window.scrollTo,不再依赖
scroll-margin-top。hash-scroll(直接访问)与 anchor-click(点击)统一调用它。

scrollToHash 额外用双 requestAnimationFrame 延后做一次即时校正,覆盖原生
fragment-scroll 的触发窗口,确保落点在原生 scroll 之后生效。

scroll-margin-top: 6rem 保留在 input.css 作其它原生 scroll 场景的兜底。
2026-07-13 18:31:31 +08:00
xfy
5ad6ce60cd fix(post): hydration 后点击标题锚点触发整页刷新
WASM 下载完成后点击 TOC/标题 # 锚点会整页刷新且不滚动,而 SSR 阶段(WASM
未就绪)点击正常——这是 Dioxus 0.7 事件委托的副作用:

项目任意位置用 onclick(post_nav_links.rs)即触发 Dioxus 全局 click 委托。
点击 dangerous_inner_html 注入的 <a href="#id"> 时,事件冒泡到根监听器,
handleEvent 发现 <a> 无 Dioxus onclick → 走兜底 handleClickNavigate:
preventDefault 拦掉原生 fragment-scroll,再 browser_open IPC 把 hash 当
外部 URL 整页加载(History::external → location.set_href)。

修复:yggdrasil-core 新增 anchor-click.ts,在 window capture 阶段(早于
Dioxus 的 bubble 委托)拦截同页 hash 锚点点击,stopPropagation 阻止事件
到达 Dioxus,自行 scrollIntoView + replaceState 更新地址栏。中键/修饰键、
外链、目标不存在等情况放行原行为。

81682eb 的 scrollToHash 只在 PostContent 挂载时跑一次,不覆盖点击场景;
本修复与之正交,共同覆盖刷新与点击两条路径。initAnchorClick 幂等。
2026-07-13 18:22:53 +08:00
xfy
56f599574f feat(slug): 中文标题自动转拼音生成 URL slug
文章 URL slug 与 markdown 标题锚点此前会吃掉中文字符:纯中文标题
退化成时间戳,混入 ascii 时只剩 ascii 片段。

引入 pinyin crate(纯 Rust、零依赖,安全交叉编译到 musl/FreeBSD),
slugify 与 slugify_heading 在字符过滤前先尝试 to_pinyin():
- 汉字 → 无声调拼音,每字成词用 - 分隔(你好 → ni-hao)
- ASCII 字母数字与 -/_ 保留
- 多音字取默认读音,博客场景足够

示例:你好世界 → ni-hao-shi-jie;Rust 入门指南 → rust-ru-men-zhi-nan

is_valid_slug 不变(原本就允许 Unicode),手动输入中文 slug 仍可用。
pinyin 为 server-only optional 依赖,不进 WASM bundle。
2026-07-13 18:04:35 +08:00
xfy
cc4c551aaa fix(code-runner): 容器清理失败重试+日志告警,避免静默泄漏
ContainerGuard::drop 之前用 let _ = 吞掉 remove_container 的错误,
Docker daemon 瞬时不可用会让容器泄漏且无人知晓。

改为最多重试 3 次(指数退避 200/400/800ms),仍失败则记录 error 级日志
(带 container_id 便于 docker rm -f 兜底)。容器清理是 fire-and-forget,
无法把错误回传业务层,所以用日志暴露故障而非 panic。
2026-07-13 17:56:42 +08:00
xfy
ee9d2ecd6e style(ui): 组件圆角类对齐三档梯度(32/16/8)
配合 input.css 的 token 化,把散落在 RSX 组件里的孤悬圆角档位
统一到内容档 16px (rounded-2xl):

消除 24px (rounded-3xl, 4处无规范依据的中间档):
- ADMIN_CARD_CLASS / ADMIN_TABLE_CLASS (ui.rs)
- write.rs 编辑器容器 / write_skeleton.rs 编辑器骨架

消除 12px (rounded-xl, 10处):
- INPUT_CLASS (forms.rs) 与 write 页输入框统一到 16px
- write 页 3 处输入框 + 4 处错误提示框
- admin_layout 退出按钮
- system/admin_skeleton/posts_trash 卡片容器
- write_skeleton 骨架条(模拟输入框形状,跟随真实圆角)

改造后全站只剩 4 种圆角语义: 32px容器 / 16px内容 / 8px微元素 /
胶囊按钮,孤悬档位彻底清除。
2026-07-13 16:21:10 +08:00
xfy
248dcb761c refactor(ui): 圆角统一为三档梯度(32/16/8)+token化
将全站圆角收敛为语义化三档梯度:
- --radius-card  32px  容器(卡片/封面/admin主面板)
- --radius-paper 16px  内容(代码块/正文图片/iframe/表格)
- --radius-mini   8px  微元素(标签/复制按钮/toc/paginav)

此前内容区代码块、正文图片圆角仅 8px,与卡片 32px 设计语言断层
4 倍;表格 16px 是硬编码字面量散落 5 处。本次:
- --radius-paper 从 8px 提到 16px,代码块/图片/iframe 随之提档
- 表格 5 处 16px 字面量改用 var(--radius-paper)(值不变,语义化)
- toc/复制按钮/标签/paginav/blur-img 拆出到新 --radius-mini:8px
- 封面 2rem 改用 var(--radius-card)(值不变,语义化)

按钮/头像/徽章另用胶囊(rounded-full),独立于本梯度,不动。
2026-07-13 16:21:02 +08:00
xfy
2310ed9abe fix(system): 备份恢复实际不写入数据 + 假成功
三层叠加 bug 导致「恢复完成却零数据变更」:

1. 备份用 pg_dump 生成但无 --clean,脚本不含 DROP。恢复到已有数据的库时,
   CREATE TABLE 全部 'already exists',COPY public.posts 在第一行就因主键
   冲突中止(COPY 0),被软删的文章(deleted_at 非空)永远回不来。

2. psql 默认不带 ON_ERROR_STOP,即使满屏 ERROR 退出码仍是 0。run_restore
   只判断 status.success(),于是 tasks::update 误报 TaskStatus::Done——
   用户看到「恢复完成」但实际零写入。

3. 即使数据写进去,前端 moka 缓存(posts/tags/stats/search)和 SSR 世代号
   也不会失效,恢复后仍读旧数据。

修复:
- run_pg_dump_backup 加 --clean --if-exists,备份脚本自带 DROP IF EXISTS,
  恢复变为幂等的先删后建。
- run_restore 给 psql 加 -v ON_ERROR_STOP=1,任何 SQL 错误立即退出(码 3),
  status.success() 不再误判。
- 恢复成功后调用 invalidate_all_post_caches + invalidate_search_results
  + bump_global_generation,前端立即看到恢复的数据。

验证:建干净测试库跑完整反馈回路(3 篇 → 软删 2 篇 → 恢复),修复前 alive=1,
修复后 alive=3,exit 0。旧备份文件(无 DROP)在 ON_ERROR_STOP 下会正确报失败
而非假成功,需重新创建备份才能恢复。

根因假设(已通过最小反馈回路证实):pg_dump 无 --clean + psql 无 ON_ERROR_STOP +
缓存未失效三层叠加。hypothesis 来自 backup.rs 源码 + backups/*.sql 实际结构 +
psql 退出码语义实测。
2026-07-13 15:57:34 +08:00
xfy
5888f1fc41 feat(system): 备份恢复删除/恢复改用 Popover 确认框替代浏览器 confirm
新增通用 Popover 组件(src/components/ui.rs,与 Tooltip 对称),定位用
position:fixed + MouseEvent::client_coordinates() 视口坐标锚定,逃出表格
overflow-hidden;透明遮罩 z-40 兜底点击外部关闭,组件内 use_effect 注册
全局 keydown 监听 Esc 关闭(use_drop 清理),150ms 淡入缩放动画
(input.css 新增 popover-enter keyframes)。

BackupRow 删除/恢复按钮 onclick 读点击坐标打开对应确认 popover,确认
按钮回调父组件。顺带简化恢复链路:原生 confirm 是阻塞式才需要
pending_restore signal + 确认 use_future 的间接机制,popover 非阻塞后
on_restore 直接 busy + restore_backup + active_task_id,移除 pending_restore
signal 及其 use_future(进度轮询 use_future 不变,仍服务创建备份+恢复)。
2026-07-13 15:08:24 +08:00
xfy
c1530956b0 style(system): 数据导出 tab 卡片宽度与其它 tab 对齐
ExportTab 根容器带了 max-w-2xl 把卡片限宽在 672px,而 DbStatusTab /
ServerStatusTab / SqlConsoleTab / BackupTab 都不限宽填满 max-w-7xl
内容区。去掉 max-w-2xl 让数据导出卡片与其它 tab 宽度一致。内部表单
输入用 w-full,加宽后自然拉伸,无空白。
2026-07-13 14:23:14 +08:00
xfy
2e40836480 fix(system): 备份/恢复任务轮询永不启动导致按钮卡死在 loading
点击「创建备份」后接口返回 task id,但按钮一直转圈、列表不刷新。
根因是 AGENTS.md 踩坑记录里的「Reactive hooks 不追踪普通 props/snapshot」:

进度轮询 use_future 在挂载时把 active_task_id 快照进 _task_id_for_poll
(彼时为 None),use_future 只运行一次即 return;用户点击后 create_backup
返回 task id 并 active_task_id.set(Some(id)),但 future 已结束、永不重跑
→ 轮询不启动 → busy 永远 true。恢复确认 use_future 有同样的快照陷阱
(_pending_for_confirm),导致点「恢复」连确认框都不弹。

改用与 DbStatusTab 自动刷新一致的长生命周期 loop 模式:use_future 只跑
一次,内部 loop 每轮迭代读 active_task_id() / pending_restore() 信号,
空闲时 200ms yield 呼吸。这样信号更新在下一轮迭代即被感知,无需依赖
ReactiveContext 对挂载期快照的订阅。
2026-07-13 13:56:22 +08:00
xfy
cf14aae17f style(ui): 统一 admin 分页按钮为圆角胶囊样式
admin 分页的上一页/下一页按钮原先用 rounded-sm 方角实心(灰黑底
+ font-mono),与本页其它操作按钮(BTN_OUTLINE 等圆角胶囊)风格
不一致。改为与描边按钮同族的 rounded-full 胶囊,hover 走主题强调
色,禁用态同步圆角。

因三个 admin 列表页(posts / posts_trash / comments)共用
Pagination 组件的 admin variant,此改动一次性统一全部后台分页。
2026-07-13 13:38:50 +08:00
xfy
093940a0d7 fix(theme): skip editor set_theme during VT animation to prevent direct jump
主题切换时,可运行代码块(CodeMirror + xterm)整体瞬切,不受 VT 圆形展开动画
控制——圆形还没展开到代码块,代码块就整体变色了。

根因(真实页面逐帧像素采样 + 事件追踪确认):
ThemeToggle::onclick 先调 __startThemeTransition(同步截 OLD 快照,VT 回调异步),
再调 theme.set(next)。theme.set 触发 Dioxus use_effect → set_theme,这个调用
在 OLD 快照截取后、VT 回调执行前的时间窗内,直接改了实时 DOM 的编辑器背景。
VT 动画播的是伪元素快照(::view-transition-old/new),但实时 DOM 的改动会穿透
伪元素,表现为代码块在圆形展开到达前就整体瞬切。

上一个提交(theme-change 事件)已在 VT 回调内同步 setTheme(进入 NEW 快照),
但 use_effect 的冗余 set_theme 仍在动画期间改实时 DOM,抵消了快照内的正确状态。

修复:CodeMirror + xterm 的 use_effect 在 is-theme-transitioning 期间跳过
set_theme 调用。VT 事件已在快照前同步换肤;动画结束后 is-theme-transitioning
被移除,use_effect 会因 resolved 信号变化重跑,做幂等兜底同步(覆盖非 VT 场景:
系统偏好变化、初始挂载)。

验证(scripts/vt-editor-sweep.mjs,真实 Dioxus 点击路径):
- 修复前:编辑器整体瞬切(t=111ms 全部变 dark,无渐变)
- 修复后:编辑器从右向左被圆形逐步揭示(t=228ms 右侧先 dark,t=313ms 左侧 dark,
  中间点显示圆形边缘抗锯齿色),与页面其他部分同步
2026-07-13 13:33:33 +08:00
xfy
f984c0c3e7 fix(theme): clear animate-page-enter transform during VT to fix code block coverage
主题切换的圆形展开 VT 动画对可运行代码块不生效:代码块直接瞬切,而非被
圆形展开揭示。上一个提交(theme-change 事件同步 setTheme)只解决了快照时序
问题,但真实环境仍不生效。

根因(真实页面逐帧截图 + 像素采样确认):article.post-single 的 animate-page-enter
入场动画用 transform: translateY/scale,因 animation-fill-mode: both 在动画结束后
保留计算值。transform: none 被浏览器序列化为 matrix(1,0,0,1,0,0),仍是「非 none」
的 transform,创建堆叠上下文(stack context)。::view-transition-old/new(root) 顶层
伪元素无法正确覆盖堆叠上下文内部的 CodeMirror/xterm——圆形展开扫过代码块区域时,
伪元素被穿透,代码块的实时 DOM 变化(use_effect 驱动的 setTheme)直接可见,表现为
「直接瞬切,不受 VT 动画控制」。

修复:在 is-theme-transitioning 期间用 !important 清除 .animate-page-enter 的
transform。is-theme-transitioning 在 startViewTransition 之前添加,OLD/NEW 快照
均含此 class,故两快照 transform 都被清为 none,一致,不影响动画效果。

验证:
- scripts/vt-real-page-sampler.mjs + vt-xterm-real-sampler.mjs:Playwright 驱动
  真实 dx serve 页面,逐帧截图 + 像素采样
- 修复前:代码块在圆形边界外已变 dark(直接瞬切);修复后:代码块随圆形展开
  同步变色(lag 与几何距离一致,非瞬切)
- 像素追踪:editor 在 t=266ms 变色(圆形到达),bg 在 t=362ms 变色(圆形到达更远点),
  时序与圆形几何一致,证明两者都通过 VT 动画变色
2026-07-13 12:02:30 +08:00
xfy
bbb77954e9 fix(theme): sync CodeMirror/xterm theme in VT callback for circular-expand animation
主题切换的圆形展开 VT 动画对可运行代码块不生效:圆形扫过 CodeMirror/xterm
区域时看不到颜色变化,动画结束后才瞬切。

根因(逐帧像素验证确认):VT 回调只 toggle .dark class,但 CodeMirror 背景
由 catppuccin Extension 注入(.cm-editor 的 EditorView.theme 规则),xterm
背景由 .xterm-scrollable-element 的 inline background-color 注入——两者都
不随 .dark 翻转。set_theme 由 Dioxus use_effect 在 theme.set() 之后异步触发,
晚于 NEW 快照捕获,导致 OLD/NEW 快照在编辑器区域同色,圆形展开无可揭示的变化。

修复:在 VT 回调内(NEW 快照捕获前)同步 dispatch 'yggdrasil:theme-change'
CustomEvent,CodeMirror/xterm 各自的 index.ts 在模块加载时订阅并遍历
_instances 同步调 setTheme。事件先于 applyDarkClass dispatch,确保编辑器换肤
+ class 翻转被同一个 getComputedStyle reflow 捕获进 NEW 快照。

与现有 Dioxus use_effect 幂等共存:setTheme 对相同主题是 no-op(CodeMirror
Compartment.reconfigure + xterm options.theme = 均幂等),use_effect 作兜底
覆盖初始挂载 / 非 VT 场景。

验证:
- yggdrasil-core 测试扩展:断言 VT 回调 / 降级路径 / applyResolvedTheme 都
  dispatch 事件,且事件先于 dark class 翻转
- scripts/vt-theme-sampler.mjs(Playwright 逐帧像素采样):修复前 xterm 比
  等距的 cssvar 探针晚 100ms 变色(bug);修复后同帧变色(lag=0ms)

改动文件:
- libs/yggdrasil-core/src/theme-transition.ts: THEME_CHANGE_EVENT 常量 +
  notifyThemeChange 辅助函数,VT 回调 / 降级路径 / applyResolvedTheme 三处 dispatch
- libs/codemirror-editor/src/index.ts: 订阅事件,forEach _instances.setTheme
- libs/xterm-terminal/src/index.ts: 同样模式
- libs/yggdrasil-core/src/theme-transition.test.ts: 3 个新用例
- public/xterm/terminal.js: 重建产物(含事件订阅)
- scripts/vt-theme-{harness,sampler}: 逐帧像素验证工具(throwaway,可作回归)
2026-07-13 11:20:08 +08:00
xfy
e50941ef24 fix(runner): propagate duration_ms through SSE done event
The SSE streaming path hardcoded '耗时: -' because duration_ms was
never threaded through:

- docker.rs: OutputChunk::Done now carries duration_ms, computed from
  start_container to wait completion via Instant::elapsed
- sse.rs: DonePayload gains duration_ms, serialized into the done event
- runner.rs: WASM done callback formats the real duration instead of '-'

The polling fallback path already had correct duration via ExecResult;
only the SSE path was missing it.
2026-07-13 10:09:15 +08:00
xfy
aa2d7f3f2d fix(runner): use python -u for unbuffered stdout streaming
Python defaults to 4KB block buffering when stdout is a pipe (not a
TTY). Docker attach uses pipes, so print() output accumulated in the
buffer and only flushed when the process exited — the 3-second
sleep(1) loop appeared as a single dump at the end.

python -u (equivalent to PYTHONUNBUFFERED=1) forces line-level
flushing, so each print() is immediately written to the pipe and
picked up by the concurrent log reader → SSE → xterm.js.

Node/Go/Rust already line-buffer to pipes, so only Python needed the
flag. stdout/stderr separation is preserved (no TTY merge).
2026-07-13 10:03:58 +08:00
xfy
2059a55e11 fix(runner): make wait_container concurrent with log reading
run_in_container_stream was sequentially awaiting wait_container before
reading the log stream. wait_container blocks until the container
exits, so by the time the log loop ran, all output was already buffered
in the attach stream and got drained near-instantly — appearing as a
one-shot dump instead of streaming.

Now uses tokio::select! to run log_reader and wait_with_timeout
concurrently:

- log_reader: drains the attach stream, pushing each chunk via tx
  immediately as it arrives
- wait_with_timeout: waits for container exit (with timeout, kills on
  timeout)

Whichever finishes first wins; the survivor is drained/handled. When
wait completes, any tail bytes still in the stream are flushed before
sending the Done chunk.

This is the root cause of the streaming not working — a sleep(1) loop
now streams line-by-line instead of dumping all at once after exit.
2026-07-13 09:59:03 +08:00
xfy
9191d07200 fix(ui): hide output area until first run; show skeleton while waiting
The output area was always visible (empty terminal container shown on
page load). Now it only appears after the user clicks Run, with three
states:

- Not run: output area hidden entirely (show_output = false)
- Running, no output yet: skeleton screen placeholder
  (running && !has_output)
- Output received: skeleton gone, xterm renders streamed content

Two new signals drive this:
- show_output: set true on Run click, controls output area visibility
- has_output: set true on first stdout/stderr chunk (SSE) or writeAll
  (polling fallback), dismisses the skeleton

xterm mount effect now reads show_output, so it retries after the
container div enters the DOM (when show_output flips true).
2026-07-13 09:49:57 +08:00
xfy
e347776dcd fix(ui): align xterm terminal background with --color-paper-code-block
xterm.js terminal background was Catppuccin Base (#eff1f5 / #1e1e2e),
but the project's code-block background CSS variable uses Catppuccin
Surface0 (#dce0e8 light / #313244 dark). The mismatch left the output
area visibly lighter (light mode) / darker (dark mode) than the
surrounding container.
2026-07-13 09:43:01 +08:00
xfy
6f4385221a test(libs): add xterm-terminal smoke tests
4 tests covering XtermOptions construction/setters and TerminalInstance
mount/onReady/writeAll/clear/destroy lifecycle under happy-dom.
2026-07-10 11:59:38 +08:00
xfy
7d153e50f4 feat(ui): CodeRunner switches to SSE + xterm.js streaming output
WASM path: start_exec_stream → EventSource SSE → xterm.js terminal.
stdout/stderr events write to the terminal in real time; done event
sets exit status and closes the connection. Falls back to polling
get_exec_result (writeAll) if EventSource creation fails.

Server path (placeholder, component runs client-side): retains the
original polling logic so both targets compile.

- xterm.js terminal mounted via xterm_bridge (mirrors CodeMirror mount
  pattern: use_effect + TerminalHandle + use_drop)
- run_code split into #[cfg(wasm32)] (SSE) and #[cfg(not)] (polling)
- output area: <pre> replaced with xterm container div
- poll_result / start_sse helpers in WASM-only sse_consumer module
- term_handle declared at component scope (cfg-gated) so the WASM
  run_code closure can capture it

Note: dx check reports use_resolved_theme-in-closure for the new xterm
effects, same as the pre-existing CodeMirror effects (lines 145, 177).
This is an existing project-wide pattern; cargo build on both targets
and cargo clippy pass clean.
2026-07-10 11:58:25 +08:00
xfy
60138ed6e1 feat(api): add SSE endpoint /api/exec/stream
GET /api/exec/stream?task_id=X — pops the mpsc Receiver from
EXEC_STREAMS (one-shot, prevents duplicate consumers), wraps it in
ReceiverStream, maps OutputChunk → SSE Event (stdout/stderr/done).
keep_alive every 15s prevents reverse-proxy idle timeouts.

Route registration: dedicated sse_route with csrf_middleware but NO
TimeoutLayer (SSE is a long-lived connection; the 30s app-route timeout
would kill it). Auth + rate limit already enforced in start_exec_stream.

main.rs merge order: upload(300s) → export(120s) → sse(no timeout) →
app(30s) → static(none).
2026-07-10 11:48:26 +08:00
xfy
1e556aa125 feat(runner): add SSE task registry and streaming executor
progress.rs:
- EXEC_STREAMS DashMap: SSE handler pops rx by task_id (one-shot)
- StreamEntry wraps rx + created_at for TTL-based GC
- gc_old_tasks now cleans both EXEC_TASKS and EXEC_STREAMS

execute.rs:
- validate_exec_request: extracted shared whitelist + size check
- check_rate_limit_for_user: extracted shared admin-aware rate limit
- start_exec refactored to use both helpers (behavior unchanged)
- start_exec_stream: new server fn, same validation chain, spawns
  run_in_container_stream(tx) + writes EXEC_TASKS for polling fallback
2026-07-10 11:45:20 +08:00
xfy
832dd756b9 feat(runner): add streaming execution path in docker.rs
run_in_container_stream: same container lifecycle + ContainerGuard
cleanup as run_in_container, but pushes output chunks to an mpsc
Sender as the log stream is read, instead of buffering until the
container exits. Also retains a full buffer for the caller to write
back to EXEC_TASKS (polling fallback path).

- OutputChunk enum: Stdout/Stderr/Done{exit_code,oom_killed,timed_out}
- client disconnect detection: tx.send fails → stops pushing but
  continues draining the log stream so the container exits cleanly
- Done chunk carries terminal status for the SSE done event
- timeout/inspect/OOM logic identical to run_in_container
- run_in_container and its 4 tests left untouched
2026-07-10 11:41:37 +08:00
xfy
7d75c5dac9 feat(bridge): add xterm_bridge.rs wasm-bindgen bindings
Mirrors codemirror_bridge.rs: Reflect::get + unchecked_into for the
IIFE object literal window.XtermTerminal, XtermOptions as wasm-bindgen
extern type (TS class survives erasure), TerminalHandle with Drop →
destroy(). WASM-only (pub mod wasm gated on target_arch = wasm32).

Dioxus.toml: register /xterm/terminal.{css,js} in both prod and dev
resource arrays. CSS is emitted as a separate file by vite lib mode
(cssCodeSplit:false still extracts one CSS file for IIFE).
2026-07-10 11:39:24 +08:00
xfy
246d8a6d67 feat(libs): add xterm-terminal IIFE library (xterm.js 6.0)
New pnpm workspace package @yggdrasil/xterm-terminal, output to
public/xterm/. Output-only terminal (disableStdin) for streaming
container stdout/stderr via SSE.

- @xterm/xterm@^6.0.0 + @xterm/addon-fit@^0.11.0 (released same day,
  2025-12-22; 0.10.0's peerDependency is ^5.0.0 and incompatible)
- convertEol: containers emit \n, xterm needs \r\n
- stderr wrapped in ANSI red for visual distinction
- Catppuccin Latte/Mocha themes matching project highlight.css
- Mirrors codemirror-editor IIFE pattern: object literal default export,
  XtermOptions as class (survives TS erasure for wasm-bindgen)
- css.d.ts declares *.css module for tsc side-effect import
2026-07-10 11:37:04 +08:00
xfy
26f7465943 feat(deps): add tokio-stream for SSE streaming
- tokio-stream 0.1 (sync feature) wraps mpsc::Receiver into a Stream
  for the upcoming axum SSE handler; axum::response::sse ships with
  axum 0.8 already so no extra dep needed there
- web-sys features: add EventSource + MessageEvent so the WASM frontend
  can consume SSE via the native EventSource API
2026-07-10 11:33:07 +08:00
xfy
d537c641c4 chore: add backups to dockerignore 2026-07-10 11:27:44 +08:00
xfy
8c88e82acd fix(docker): make server build arch-aware for multi-arch buildx
Cross-compiling x86_64-unknown-linux-musl from an arm64 buildx leg breaks
ring: cc-rs emits -m64 for the x86_64 target but hands it to Debian's
arm64 musl-gcc (shipped for host arch only), whose cc1 has no -m64 →
'cc1: error: unrecognized command-line option "-m64"'.

Each buildx platform leg now builds its own native musl target
(amd64→x86_64, arm64→aarch64) so musl-gcc and the target always match —
no cross-compiler, no QEMU for the compile step. The built binary is
staged at an arch-independent path so the scratch runtime stage can COPY
it without knowing which target was used.

Makefile gains a docker-multiarch target (buildx --platform
linux/amd64,linux/arm64 --push); 'make docker' now uses buildx --load
for local single-arch testing.
2026-07-10 11:26:03 +08:00
xfy
914562a9e5 feat(docker): route all build downloads through China mirrors
Container内无法访问宿主机的 127.0.0.1:10808 代理(Docker Desktop NAT),
官方源在容器内要么被透明代理截断(NOSPLIT)、要么限速到 16 KB/s。把
所有网络下载源换成国内镜像,实测在容器内:

  - Debian apt   -> TUNA         3.9 MB/s (apt-get update 3s)
  - Node.js 22   -> npmmirror    二进制 mirror,200
  - npm/pnpm     -> npmmirror    registry.npmmirror.com,200
  - Rust/rustup  -> rsproxy.cn   200
  - crates.io    -> rsproxy      sparse index,200
  - Tailwind v4  -> gh-proxy.com 430 KB/s(106MB 完整下载,vs 代理 16KB/s)

镜像地址全部提为 ARG(默认国内源),需要时可 --build-arg 切回官方。
GitHub release 二进制不进宿主代理(大文件限速),单独走国内 GitHub 反代
CDN;GH_PROXY 置空可回退直连。
2026-07-10 10:35:06 +08:00
xfy
0a5dd9c67a fix(docker): use HTTPS Debian mirrors to bypass transparent HTTP interception
A transparent HTTP proxy on the build host (http_proxy=127.0.0.1:10808)
truncates apt's plain-HTTP downloads to a 2578-byte HTML error page, which
apt rejects with 'Clearsigned file isn't valid, got NOSPLIT'. curl/wget
inside the same container fetch the full 151074-byte InRelease fine, and
HTTPS apt sources work — so switch the builder's Debian sources to HTTPS.

Proven by: apt-get update over HTTPS fetched 9264 kB successfully (4m27s
through the throttled proxy); plain HTTP failed with NOSPLIT every time.
2026-07-10 09:41:48 +08:00
xfy
81682eb70e fix(post): 修复 hash 锚点跳转失效
内容异步加载(use_server_future)导致骨架屏阶段标题 DOM 缺失,浏览器原生
fragment-scroll 找不到目标留在顶部;刷新页面也因 SSR 出的是骨架屏而失效。

- 新增 __scrollToHash 全局入口(yggdrasil-core):内容挂载后解码
  location.hash(CJK 百分号编码 → 原始字符)并 scrollIntoView
- PostContent use_effect 末尾复用 invoke_optional_global 调用,标题
  DOM 已就绪时补一次滚动
- 标题加 scroll-margin-top: 6rem,避开 80px sticky header

点击 TOC/标题 hashtag 由原生 anchor + scroll-margin-top 覆盖,无需 JS。
2026-07-10 00:21:08 +08:00
xfy
3baa0a87ba fix(ui): --font-sans 补齐 CJK sans 字体栈
Tailwind v4 默认 --font-sans(ui-sans-serif, system-ui, sans-serif)不含中文字体。
后台 AdminLayout 根容器声明 font-sans 后,绕过 body 里精心配置的
'Noto Sans SC'/'PingFang SC'/'Microsoft YaHei' 栈,中文 fallback 到系统衬线体,
导致 /admin/* 页面中文(标题、列表项等)渲染成衬线体。

在 @theme 重定义 --font-sans,对齐 body 字体栈,根治全项目任何 font-sans 用法。
--default-font-family 指向 --font-sans,顺带强化 body 默认字体。
2026-07-09 23:37:16 +08:00
xfy
1584e01425 chore(deps): update all cargo and pnpm dependencies to latest versions 2026-07-09 18:12:54 +08:00
xfy
ecd5b441a0 fix(code-runner): 补齐可运行块上方间距
上一提交只加了 mb(下间距),但上间距仍缺失:PostContent 把每个 HTML
片段包在独立 <div> 里(post_content.rs:162),代码块前的段落成了该 div
的 :last-child,命中 .md-content p:last-child { margin-bottom:0 },
margin 折叠后上方间距归零。

把 mb 改成 my,var() 取同样的 --content-gap-paper(20px),由 CodeRunner
自身的 margin-top 保证上间距,不再依赖上方段落的 margin 是否被 :last-child
规则清零。
2026-07-09 18:03:30 +08:00