6.5 KiB
6.5 KiB
编辑器重构设计文档
日期: 2026-06-02
状态: 已批准,P0 先行
作者: Agent
1. 背景与目标
FileEditorWorkbench.vue(822 行)存在三类正确性问题,优先级 P0:
| 编号 | 问题 | 现状证据 | 风险 |
|---|---|---|---|
| M1 | 共享 model → 撤销历史串台 | applyTabToEditor 对同一 model 调用 setValue |
编辑 A 文件的撤销操作回到 B 文件内容 |
| M2 | 强制 LF,丢失原始 EOL | normalizeEol() 在读入/写出双端截断 \r\n |
CRLF 脚本/配置被破坏 |
| M3 | 覆盖写没有冲突检测 | write-file 直接覆盖,不校验 mtime/hash |
并发编辑/外部修改静默丢失 |
本期 P0 目标:解决 M1/M2/M3。P1/P2(架构拆分、大文件只读、Diff 视图)后续迭代。
2. 方案对比
M1:per-tab model vs 切换时 setValue
| 方案 | 优 | 劣 |
|---|---|---|
| A:每标签独立 ITextModel + 保存/恢复 ViewState | 撤销独立;光标/折叠/选区保留;Monaco 推荐 | tab 状态增加两个字段(model ref、viewState) |
| B:切换时销毁重建 editor | 简单 | 贵(初始化时间);光标丢失 |
选 A。每个 EditorTabState 增加 model: ITextModel | null 和 viewState: ICodeEditorViewState | null。
M2:EOL 处理
| 方案 | 优 | 劣 |
|---|---|---|
| A:探测原始 EOL,Monaco eolPreference 匹配,保存时还原 | 无损保存 | 实现稍复杂 |
| B:保持现有 LF-only | 简单 | 破坏 Windows 脚本 |
选 A。读入时探测 \r\n(CRLF)or \n(LF),存入 tab.eol('LF' | 'CRLF'),Monaco defaultEOL 匹配,保存时按原始 EOL 序列化。
M3:冲突检测
| 方案 | 优 | 劣 |
|---|---|---|
| A:读时记录 mtime+sha256;写时校验,不一致返回 409,前端提示 | 安全 | 多一次 SSH stat+sha256sum |
| B:乐观锁(客户端存 hash,不校验) | 减少服务端改动 | 无保护 |
| C:前端 read 后即时保存,不校验 | 无改动 | 当前方案,无保护 |
选 A。read-file 一次返回 content + mtime + etag(sha256);write-file 接受可选 etag,若提供则在写前 sha256sum 校验,不一致返回 409;前端弹出三选一:覆盖 / 放弃 / 对比。
3. 接口与数据模型变更
3.1 后端 Pydantic Schema
# FileRead 响应(不改请求体)
{
"content": str,
"size": int,
"path": str,
"mtime": int, # NEW: Unix 秒,来自 stat -c%Y
"etag": str, # NEW: sha256hex,来自 sha256sum
"eol": "LF"|"CRLF" # NEW: 服务端探测
}
# FileWrite 请求体(新增可选字段)
class FileWrite(BaseModel):
server_id: int
path: str
content: str
etag: str | None = None # NEW: 若提供,校验当前 sha256;不一致 → 409
3.2 前端 EditorTabState
export interface EditorTabState {
id: string
path: string
name: string
content: string
originalContent: string
modified: boolean
languageId: string | null
// NEW
eol: 'LF' | 'CRLF' // 原始行尾
etag: string | null // 服务端 sha256,用于冲突检测
mtime: number | null // 服务端 mtime
model: import('monaco-editor').editor.ITextModel | null // 独立 model
viewState: import('monaco-editor').editor.ICodeEditorViewState | null
}
3.3 filePreload.ts 返回类型
export interface RemoteFileReadResult {
content: string
mtime: number | null
etag: string | null
eol: 'LF' | 'CRLF'
}
4. 实现要点
4.1 per-tab model(M1)
openFile(payload):
if existing tab → editor.setModel(tab.model); editor.restoreViewState(tab.viewState)
else → createModel(normalizeEol(content), lang, Uri.file(path))
onTabSelected(id):
persistEditorToActiveTab() // content + viewState
activeTabId = id
editor.setModel(newTab.model)
editor.restoreViewState(newTab.viewState)
persistEditorToActiveTab():
tab.viewState = editor.saveViewState()
tab.content = editor.getValue() // 不 normalizeEol
tab.modified = tab.content !== tab.originalContent
removeTab(id):
tab.model?.dispose()
tabs.splice(idx, 1)
...
destroyEditor():
// 不再 dispose model(每 tab 独立,removeTab 负责)
editor?.dispose(); editor = null
4.2 EOL 保留(M2)
function detectEol(raw: string): 'LF' | 'CRLF' {
return raw.includes('\r\n') ? 'CRLF' : 'LF'
}
function serializeContent(content: string, eol: 'LF' | 'CRLF'): string {
const lf = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
return eol === 'CRLF' ? lf.replace(/\n/g, '\r\n') : lf
}
Monaco createModel 时通过 monaco.editor.DefaultEndOfLine.LF/CRLF 匹配原始 EOL。
4.3 冲突检测(M3)
后端 read-file(单次 SSH 合并命令):
# 合并 stat+sha256sum 减少往返
stat -c '%s %Y' /path && sha256sum /path && cat /path
实际拆为两次调用(保持可维护性):
stat -c '%s %Y' path_q→ size + mtimesha256sum path_q→ etagcat path_q→ content(现有逻辑)
后端 write-file 校验流程:
if payload.etag:
r = await exec_ssh_command_with_fallback(server, f"sha256sum {path_q}", timeout=5)
current_etag = r["stdout"].split()[0] if r["exit_code"] == 0 else None
if current_etag != payload.etag:
raise HTTPException(status_code=409, detail="文件已被外部修改,etag 不匹配")
原子写(防中途崩溃丢数据):通过 _ssh_write_bytes_via_tee 写到 .tmp_nexus_* 后 mv(asyncssh_pool.py 已实现 tmp+mv 逻辑,仅需确认路径)。
前端 409 处理:
409 → 弹出 ConflictDialog:
① 覆盖(force save,etag=null)
② 放弃(discard)
③ 对比(暂 P1,disabled)
5. 安全约束
etag仅用于校验,服务端重新计算,不信任客户端值的正确性sha256sum不开 sudo,权限不足时退化为跳过 etag 校验(非致命)- 原始 EOL 探测在前端完成(
read-file仅辅助),服务端也标记防止缓存污染
6. 验收标准
- 打开 A、B 两文件,编辑 B 后切回 A,Ctrl+Z 撤销不影响 B 的历史
- 上传含 CRLF 的文件,编辑保存,下载后
file命令显示 CRLF - 同一文件两个标签页同时编辑,后保存方收到 409 并提示覆盖/放弃
pytest tests/test_editor_write_conflict.py全通ruff check server/0 errors