203 lines
6.5 KiB
Markdown
203 lines
6.5 KiB
Markdown
|
|
# 编辑器重构设计文档
|
|||
|
|
|
|||
|
|
**日期**: 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
|