Files
Nexus/docs/design/specs/2026-06-02-editor-refactor-design.md
T

203 lines
6.5 KiB
Markdown
Raw Normal View 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 | null``viewState: ICodeEditorViewState | null`
### M2EOL 处理
| 方案 | 优 | 劣 |
|------|----|----|
| **A:探测原始 EOLMonaco eolPreference 匹配,保存时还原** | 无损保存 | 实现稍复杂 |
| B:保持现有 LF-only | 简单 | 破坏 Windows 脚本 |
**选 A**。读入时探测 `\r\n`CRLFor `\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 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
```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 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