Files
Nexus/docs/design/specs/2026-06-02-editor-refactor-design.md
T
2026-07-08 22:31:31 +08:00

6.5 KiB
Raw Blame History

编辑器重构设计文档

日期: 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. 方案对比

M1per-tab model vs 切换时 setValue

方案
A:每标签独立 ITextModel + 保存/恢复 ViewState 撤销独立;光标/折叠/选区保留;Monaco 推荐 tab 状态增加两个字段(model ref、viewState
B:切换时销毁重建 editor 简单 贵(初始化时间);光标丢失

选 A。每个 EditorTabState 增加 model: ITextModel | nullviewState: ICodeEditorViewState | null

M2EOL 处理

方案
A:探测原始 EOLMonaco eolPreference 匹配,保存时还原 无损保存 实现稍复杂
B:保持现有 LF-only 简单 破坏 Windows 脚本

选 A。读入时探测 \r\nCRLFor \nLF),存入 tab.eol'LF' | 'CRLF'),Monaco defaultEOL 匹配,保存时按原始 EOL 序列化。

M3:冲突检测

方案
A:读时记录 mtime+sha256;写时校验,不一致返回 409,前端提示 安全 多一次 SSH stat+sha256sum
B:乐观锁(客户端存 hash,不校验) 减少服务端改动 无保护
C:前端 read 后即时保存,不校验 无改动 当前方案,无保护

选 Aread-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 modelM1

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

实际拆为两次调用(保持可维护性):

  1. stat -c '%s %Y' path_q → size + mtime
  2. sha256sum path_q → etag
  3. cat 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_*mvasyncssh_pool.py 已实现 tmp+mv 逻辑,仅需确认路径)。

前端 409 处理

409 → 弹出 ConflictDialog
  ① 覆盖(force saveetag=null
  ② 放弃(discard
  ③ 对比(暂 P1disabled

5. 安全约束

  • etag 仅用于校验,服务端重新计算,不信任客户端值的正确性
  • sha256sum 不开 sudo,权限不足时退化为跳过 etag 校验(非致命)
  • 原始 EOL 探测在前端完成(read-file 仅辅助),服务端也标记防止缓存污染

6. 验收标准

  1. 打开 A、B 两文件,编辑 B 后切回 A,Ctrl+Z 撤销不影响 B 的历史
  2. 上传含 CRLF 的文件,编辑保存,下载后 file 命令显示 CRLF
  3. 同一文件两个标签页同时编辑,后保存方收到 409 并提示覆盖/放弃
  4. pytest tests/test_editor_write_conflict.py 全通
  5. ruff check server/ 0 errors