feat(hermes): Batch 3 §L - 文件管理器(限定在 ~/.hermes 子树内)

校对:Hermes 没有 file HTTP API,必须自建。

## Rust 后端:3 个安全 fs 命令(~180 行)

### 安全策略
- 所有路径必须在 hermes_home() 子树内(防 path traversal)
- canonicalize 后用 starts_with 验证
- 拒绝绝对路径跳出 + .. 跳出
- 5 MB 文件大小上限
- 单次列目录最多 2000 条

### 命令
- hermes_fs_list(path) → { path, entries: [{name, kind, size, modified}] }
  · 隐藏文件默认不显示(.env 除外)
  · 排序:目录在前,文件在后,按名字
- hermes_fs_read(path) → { path, size, text?, binary_b64? }
  · UTF-8 文本 → text
  · 二进制 → 内置 base64 编码(不引新依赖)
- hermes_fs_write(path, content) → { path, size }
  · 父目录必须存在
  · 5 MB 上限

## 前端 /h/files 页面(~270 行)

### UI 布局
- 左侧:面包屑 + 目录树(点目录进入 / 点文件选中)
- 右侧:编辑器(文本)/ 预览(图片)/ 元信息(其他二进制)

### 文件树
- 文件图标按扩展名(📁/🔗/🖼️/📝/⚙️/📄)
- 文件大小显示(B/KB/MB)
- ".." 返回上级
- 选中态高亮

### 编辑器
- textarea 编辑,monospace 字体
- "保存 *" 状态标记 dirty
- 切目录/文件前 showConfirm 「丢弃未保存修改?」
- 保存成功 toast

### 二进制预览
- 图片 (png/jpg/gif/webp/svg) → 用 data: URL 显示
- 其他 → 「二进制文件(不可编辑)」+ 文件大小

## sidebar
- 「管理」section 加文件管理器入口(folder icon)
- /h/files 路由注册(含意外的 routes 顺序修复)

### dev-api.js
- Web 模式走 Node fs.readdirSync/readFileSync/writeFileSync
- 同样的安全策略:根目录 hermes_home() = process.env.HERMES_HOME 或 ~/.hermes
- realpath 验证 + 5 MB 限制

### CSS(~165 行)
- .hm-files-layout: grid 双栏(响应式 → 单栏)
- .hm-files-tree: 左侧树,breadcrumb + entry 列表
- .hm-files-pane: 右侧编辑器/预览
- .hm-files-editor: 全宽 textarea,monospace
- 响应式:768px 以下单栏

### i18n
- 12 个新键 × 3 语言(hermesFiles*)

## 修复
- src/engines/hermes/index.js 末尾多余 `}` 删除(之前 edit 留下)

## 累计
- Rust ~180 行 + 前端 ~270 行 + CSS ~165 行 + dev-api ~55 行
- 12 个 i18n × 3 语言
- cargo check ✓ + npm build ✓
This commit is contained in:
晴天
2026-05-14 05:36:50 +08:00
parent 0d6c4614e4
commit 129d8c0ac1
8 changed files with 732 additions and 1 deletions

View File

@@ -7225,6 +7225,63 @@ const handlers = {
return await resp.json().catch(() => ({ ok: true }))
},
// Batch 3 §L: 文件管理器Web 模式走 Node fs限定 hermes_home 子树)
_hermesHome() {
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
if (process.env.HERMES_HOME) return process.env.HERMES_HOME
return path.join(os.homedir(), '.hermes')
},
_validateFsPath(rel) {
const fs = require('node:fs')
const path = require('node:path')
const root = handlers._hermesHome()
const target = rel ? path.resolve(root, rel) : root
const canonRoot = fs.realpathSync.native?.(root) || root
if (!target.startsWith(canonRoot)) throw new Error(`路径不能跳出 ${root}`)
return target
},
async hermes_fs_list({ path: p = '' } = {}) {
const fs = require('node:fs')
const target = handlers._validateFsPath(p)
if (!fs.existsSync(target)) throw new Error(`目录不存在: ${target}`)
const stat = fs.statSync(target)
if (!stat.isDirectory()) throw new Error(`不是目录: ${target}`)
let entries = fs.readdirSync(target, { withFileTypes: true }).filter(e => !e.name.startsWith('.') || e.name === '.env')
entries = entries.slice(0, 2000).map(e => {
const sub = require('node:path').join(target, e.name)
const m = fs.statSync(sub)
return {
name: e.name,
kind: e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'symlink' : 'file',
size: e.isFile() ? m.size : null,
modified: Math.floor(m.mtimeMs / 1000),
}
})
entries.sort((a, b) => a.kind !== b.kind ? (a.kind === 'dir' ? -1 : 1) : a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
return { path: target, entries }
},
async hermes_fs_read({ path: p } = {}) {
const fs = require('node:fs')
const target = handlers._validateFsPath(p)
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error(`不是文件: ${target}`)
const stat = fs.statSync(target)
if (stat.size > 5 * 1024 * 1024) throw new Error(`文件过大 (${stat.size} bytes)`)
const buf = fs.readFileSync(target)
let text = null, binary_b64 = null
try { text = buf.toString('utf8'); if (text.includes('\u0000')) { text = null; binary_b64 = buf.toString('base64') } }
catch { binary_b64 = buf.toString('base64') }
return { path: target, size: stat.size, text, binary_b64 }
},
async hermes_fs_write({ path: p, content } = {}) {
const fs = require('node:fs')
const target = handlers._validateFsPath(p)
if (Buffer.byteLength(content || '', 'utf8') > 5 * 1024 * 1024) throw new Error('内容过大')
fs.writeFileSync(target, content || '', 'utf8')
return { path: target, size: fs.statSync(target).size }
},
// Batch 2 §G: 多 GatewayWeb 模式不支持本地进程管理)
hermes_multi_gateway_list() { return [] },
hermes_multi_gateway_add() { throw new Error('Web 模式不支持多 Gateway 管理(请使用桌面客户端)') },