# 编辑器重构设计文档

**日期**: 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

```python
# 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

```typescript
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` 返回类型

```typescript
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）

```typescript
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 合并命令）：
```bash
# 合并 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 校验流程**：
```python
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. 验收标准

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
