refactor(editor): P0 — per-tab model, EOL preservation, conflict detection (409)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Changelog — 编辑器重构 P0
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**模块**: 文件编辑器(FileEditorWorkbench + 后端 read/write-file)
|
||||
|
||||
---
|
||||
|
||||
## 变更摘要
|
||||
|
||||
修复编辑器三个 P0 正确性问题:
|
||||
|
||||
1. **撤销历史串台**(M1):改为每个标签页独立持有 Monaco `ITextModel`,切换时通过 `editor.setModel()` + `saveViewState`/`restoreViewState` 保留光标、折叠、选区,撤销历史互不影响。
|
||||
|
||||
2. **EOL 强制 LF**(M2):`read-file` 响应新增 `eol` 字段(LF/CRLF);编辑器内部用 LF-only 做对比,保存时按原始 EOL 还原。CRLF 文件不再被破坏。
|
||||
|
||||
3. **并发写覆盖**(M3):`read-file` 新增 `mtime`、`etag`(sha256hex);`write-file` 接受可选 `etag`,写前校验,不一致返回 HTTP 409;前端弹出冲突弹窗(覆盖/放弃)。
|
||||
|
||||
---
|
||||
|
||||
## 动机
|
||||
|
||||
- M1:多标签编辑时,A 文件的撤销会回到 B 文件内容(共享同一 Monaco model 的 `setValue` 导致)
|
||||
- M2:CRLF 脚本/配置保存后被改成 LF,破坏 Windows 批处理等文件
|
||||
- M3:并发场景(两个管理员、外部工具)下静默覆盖,数据丢失无提示
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/api/schemas.py` | `FileWrite` 加 `etag: str | None` 字段 |
|
||||
| `server/api/sync_v2.py` | `read_file` 返回 mtime/etag/eol;`write_file` 加 etag 校验(409)和写后更新 etag |
|
||||
| `frontend/src/utils/filePreload.ts` | 新增 `RemoteFileReadResult` 接口,缓存改存完整结果 |
|
||||
| `frontend/src/composables/files/useFilesEditor.ts` | 新增 `OpenFilePayload` 接口,`editFile`/`previewFile` 适配新返回类型 |
|
||||
| `frontend/src/components/FileEditorWorkbench.vue` | per-tab model + viewState;EOL 保留;409 冲突弹窗 |
|
||||
| `tests/test_editor_write_conflict.py` | 11 个 pytest 用例,覆盖 etag 校验逻辑 |
|
||||
| `docs/design/specs/2026-06-02-editor-refactor-design.md` | 设计文档 |
|
||||
| `docs/design/plans/2026-06-02-editor-refactor.md` | 技术文档 |
|
||||
|
||||
---
|
||||
|
||||
## 是否需要迁移/重启
|
||||
|
||||
- **后端**:需 `supervisorctl restart nexus`(API 响应字段变更)
|
||||
- **前端**:需重新构建并上传(bundle 变更)
|
||||
- **数据库**:无 migration
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. `python -m ruff check server/api/schemas.py server/api/sync_v2.py` → 0 errors
|
||||
2. `python -m pytest tests/test_editor_write_conflict.py -v` → 11 passed
|
||||
3. `npx vite build` → build succeeded, 0 TS errors
|
||||
4. 浏览器:打开两个文件 A、B,编辑 B 后切回 A,Ctrl+Z 只撤销 A 的历史
|
||||
5. 浏览器:上传 CRLF 文件,编辑后保存,下载验证 CRLF 保留
|
||||
6. 浏览器:同一文件两标签保存,后者收到冲突弹窗
|
||||
@@ -0,0 +1,90 @@
|
||||
# 编辑器重构实施计划(P0)
|
||||
|
||||
**日期**: 2026-06-02
|
||||
**设计文档**: `docs/design/specs/2026-06-02-editor-refactor-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `server/api/schemas.py` | 修改 | `FileRead` 响应无变化;`FileWrite` 加 `etag: str | None` |
|
||||
| `server/api/sync_v2.py` | 修改 | `read_file` 加 mtime/etag/eol;`write_file` 加 etag 校验 |
|
||||
| `frontend/src/utils/filePreload.ts` | 修改 | `readRemoteFileContent` 返回 `RemoteFileReadResult` |
|
||||
| `frontend/src/composables/files/useFilesEditor.ts` | 修改 | `editFile`/`previewFile` 接收新返回类型;`saveActive` 传 etag |
|
||||
| `frontend/src/components/FileEditorWorkbench.vue` | 修改 | per-tab model;EOL 保留;409 冲突弹窗 |
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### Step 1:后端 schemas.py — FileWrite 加 etag
|
||||
|
||||
```python
|
||||
class FileWrite(BaseModel):
|
||||
...
|
||||
etag: str | None = Field(None, max_length=64) # sha256hex
|
||||
```
|
||||
|
||||
### Step 2:后端 sync_v2.py — read_file 加 mtime/etag/eol
|
||||
|
||||
- `stat -c '%s %Y'` → size + mtime(已有,扩展解析 mtime)
|
||||
- 新增 `sha256sum path_q`(timeout=5)
|
||||
- 返回 `{"content":..., "size":..., "path":..., "mtime": int, "etag": str, "eol": "LF"|"CRLF"}`
|
||||
|
||||
### Step 3:后端 sync_v2.py — write_file 加 etag 校验
|
||||
|
||||
- 如 `payload.etag` 非空,先 `sha256sum` 校验
|
||||
- 不一致 → 409 `{"detail": "文件已被外部修改", "code": "ETAG_MISMATCH"}`
|
||||
- sha256sum 命令失败(权限等)→ 跳过校验(降级,不报错)
|
||||
|
||||
### Step 4:前端 filePreload.ts — 返回类型扩展
|
||||
|
||||
- 新增 `interface RemoteFileReadResult { content, mtime, etag, eol }`
|
||||
- `readRemoteFileContent` 签名改为返回 `Promise<RemoteFileReadResult>`
|
||||
- 缓存存 `RemoteFileReadResult`(非纯 string)
|
||||
- 更新 `invalidateCachedFile`、`isPreloadEligible` 调用方
|
||||
|
||||
### Step 5:前端 useFilesEditor.ts — 适配新返回类型
|
||||
|
||||
- `editFile`:接收 `RemoteFileReadResult`,传给 `editorWorkbench.openFile`
|
||||
- `previewFile`:只取 `.content`,其余不变
|
||||
- 新增 `onConflict` 回调签名
|
||||
|
||||
### Step 6:前端 FileEditorWorkbench.vue — per-tab model + EOL + 409
|
||||
|
||||
- `EditorTabState` 加 `eol / etag / mtime / model / viewState`
|
||||
- `openFile`:`monaco.editor.createModel` with EOL 匹配,不 `normalizeEol`
|
||||
- `applyTabToEditor`:改为 `editor.setModel(tab.model)` + `restoreViewState`
|
||||
- `persistEditorToActiveTab`:加 `tab.viewState = editor.saveViewState()`
|
||||
- `removeTab`:加 `tab.model?.dispose()`
|
||||
- `destroyEditor`:不 dispose tab models(改由 removeTab/closePanel 负责)
|
||||
- `saveActive`:请求体加 `etag: tab.etag`,捕获 409 → `showConflictDialog`
|
||||
- 新增 `ConflictDialog`(覆盖/放弃两个按钮)
|
||||
|
||||
### Step 7:测试
|
||||
|
||||
- `tests/test_editor_write_conflict.py`(新增)
|
||||
- 写入后 etag 变更 → 409
|
||||
- etag=null → 无校验直接写入
|
||||
- sha256sum 失败 → 降级通过
|
||||
|
||||
---
|
||||
|
||||
## 依赖与配置变更
|
||||
|
||||
- 无新依赖
|
||||
- 后端:仅 schema + API handler 变更,无 DB migration
|
||||
|
||||
## 回滚方式
|
||||
|
||||
- 前端:`git revert` 对应 commit,重新 build+deploy
|
||||
- 后端:`git revert` + `supervisorctl restart nexus`
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. 双标签撤销互不影响
|
||||
2. CRLF 文件保存后不变为 LF
|
||||
3. etag 不一致时收到 409
|
||||
4. etag=null(首次打开未保存过)写入正常
|
||||
@@ -0,0 +1,202 @@
|
||||
# 编辑器重构设计文档
|
||||
|
||||
**日期**: 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
|
||||
@@ -202,6 +202,26 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Conflict dialog: file modified externally since last read -->
|
||||
<v-dialog v-model="showConflictDialog" max-width="440" persistent>
|
||||
<v-card border>
|
||||
<v-card-title class="text-warning">
|
||||
<v-icon start>mdi-alert-outline</v-icon>
|
||||
文件冲突
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
「{{ conflictTabName }}」已被外部修改。<br>
|
||||
<strong>覆盖</strong>会以当前编辑内容覆盖服务器版本;<strong>放弃</strong>会丢弃本地修改。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="cancelConflict">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="discardConflict">放弃本地修改</v-btn>
|
||||
<v-btn color="warning" variant="flat" @click="forceOverwrite">强制覆盖</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -212,10 +232,10 @@ import { useFloatingPanel } from '@/composables/useFloatingPanel'
|
||||
import { applyNexusEditorTheme, buildMonacoEditorOptions, initMonaco } from '@/monaco/initMonaco'
|
||||
import {
|
||||
EDITOR_LANGUAGE_OPTIONS,
|
||||
detectEditorLanguage,
|
||||
resolveTabLanguage,
|
||||
} from '@/utils/editorLanguage'
|
||||
import { normalizeRemotePath, parentRemotePath } from '@/utils/remotePath'
|
||||
import type { OpenFilePayload } from '@/composables/files/useFilesEditor'
|
||||
|
||||
const FileDirectoryTree = defineAsyncComponent(() => import('@/components/FileDirectoryTree.vue'))
|
||||
|
||||
@@ -227,11 +247,22 @@ export interface EditorTabState {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
/** LF-normalised content for comparison; Monaco model holds the canonical content */
|
||||
content: string
|
||||
originalContent: string
|
||||
modified: boolean
|
||||
/** `auto` 或 null 表示按文件名检测 */
|
||||
/** `null` = auto-detect from filename */
|
||||
languageId: string | null
|
||||
/** Original EOL detected from server response; preserved on save */
|
||||
eol: 'LF' | 'CRLF'
|
||||
/** sha256hex from last read/write; used for conflict detection on save */
|
||||
etag: string | null
|
||||
/** Unix mtime from last read */
|
||||
mtime: number | null
|
||||
/** Each tab owns its Monaco model; disposed when tab is closed */
|
||||
model: Monaco.editor.ITextModel | null
|
||||
/** Saved editor view state (scroll, cursor, selections) for this tab */
|
||||
viewState: Monaco.editor.ICodeEditorViewState | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -271,9 +302,12 @@ const cursorColumn = ref(1)
|
||||
const treeBrowsePath = ref(props.currentPath || '/')
|
||||
const wordWrap = ref(false)
|
||||
|
||||
// Conflict dialog state
|
||||
const showConflictDialog = ref(false)
|
||||
const conflictTabId = ref<string | null>(null)
|
||||
|
||||
const editorHost = ref<HTMLElement>()
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
|
||||
let model: Monaco.editor.ITextModel | null = null
|
||||
let layoutObserver: ResizeObserver | null = null
|
||||
|
||||
const pendingCloseTabId = ref<string | null>(null)
|
||||
@@ -308,21 +342,28 @@ const closeConfirmText = computed(() => {
|
||||
: `「${name}」已修改但未保存,确定要关闭吗?`
|
||||
})
|
||||
|
||||
function normalizeEol(text: string): string {
|
||||
const conflictTabName = computed(() => {
|
||||
if (!conflictTabId.value) return ''
|
||||
return tabs.value.find((t) => t.id === conflictTabId.value)?.name ?? ''
|
||||
})
|
||||
|
||||
// ─────────────────────── helpers ───────────────────────
|
||||
|
||||
/** Strip CR to get LF-only content for internal comparison */
|
||||
function stripCr(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
/** Restore original EOL sequence for saving */
|
||||
function serializeContent(content: string, eol: 'LF' | 'CRLF'): string {
|
||||
const lf = stripCr(content)
|
||||
return eol === 'CRLF' ? lf.replace(/\n/g, '\r\n') : lf
|
||||
}
|
||||
|
||||
function tabLanguageId(tab: EditorTabState): string {
|
||||
return resolveTabLanguage(tab.name, tab.path, tab.languageId)
|
||||
}
|
||||
|
||||
function onLanguageChoiceChange(choice: string | null) {
|
||||
if (!activeTab.value || !model) return
|
||||
activeTab.value.languageId = choice === 'auto' || !choice ? null : choice
|
||||
const lang = tabLanguageId(activeTab.value)
|
||||
monaco.editor.setModelLanguage(model, lang)
|
||||
}
|
||||
|
||||
function tabId(path: string): string {
|
||||
return `${props.serverId}:${normalizeRemotePath(path)}`
|
||||
}
|
||||
@@ -344,13 +385,6 @@ function onToolbarMouseDown(e: MouseEvent) {
|
||||
startDrag(e)
|
||||
}
|
||||
|
||||
function persistEditorToActiveTab() {
|
||||
if (!editor || !activeTab.value) return
|
||||
const value = normalizeEol(editor.getValue())
|
||||
activeTab.value.content = value
|
||||
activeTab.value.modified = value !== activeTab.value.originalContent
|
||||
}
|
||||
|
||||
function updateCursorStatus() {
|
||||
if (!editor) return
|
||||
const pos = editor.getPosition()
|
||||
@@ -358,27 +392,71 @@ function updateCursorStatus() {
|
||||
cursorColumn.value = pos?.column ?? 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current editor content and view state back to the active tab.
|
||||
* Must be called before switching tabs or saving.
|
||||
*/
|
||||
function persistEditorToActiveTab() {
|
||||
if (!editor || !activeTab.value) return
|
||||
const tab = activeTab.value
|
||||
tab.viewState = editor.saveViewState()
|
||||
const raw = editor.getValue()
|
||||
tab.content = stripCr(raw)
|
||||
tab.modified = tab.content !== tab.originalContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy only the editor instance (not the per-tab models).
|
||||
* Models are owned by tabs and disposed in removeTab / closeAllTabs.
|
||||
*/
|
||||
function destroyEditor() {
|
||||
layoutObserver?.disconnect()
|
||||
layoutObserver = null
|
||||
editor?.dispose()
|
||||
editor = null
|
||||
model?.dispose()
|
||||
model = null
|
||||
ready.value = false
|
||||
}
|
||||
|
||||
function applyTabToEditor(tab: EditorTabState) {
|
||||
if (!editor) return
|
||||
const lang = tabLanguageId(tab)
|
||||
const content = normalizeEol(tab.content)
|
||||
if (model) {
|
||||
model.setValue(content)
|
||||
monaco.editor.setModelLanguage(model, lang)
|
||||
} else {
|
||||
model = monaco.editor.createModel(content, lang, monaco.Uri.file(tab.path))
|
||||
editor.setModel(model)
|
||||
/** Dispose all tab models and reset state (called on panel close) */
|
||||
function closeAllTabs() {
|
||||
for (const tab of tabs.value) {
|
||||
tab.model?.dispose()
|
||||
}
|
||||
tabs.value = []
|
||||
activeTabId.value = null
|
||||
destroyEditor()
|
||||
maximized.value = false
|
||||
minimized.value = false
|
||||
}
|
||||
|
||||
function onLanguageChoiceChange(choice: string | null) {
|
||||
const tab = activeTab.value
|
||||
if (!tab || !tab.model) return
|
||||
tab.languageId = choice === 'auto' || !choice ? null : choice
|
||||
monaco.editor.setModelLanguage(tab.model, tabLanguageId(tab))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the editor to show the given tab's model, restoring its view state.
|
||||
* The editor instance must already exist.
|
||||
*/
|
||||
function switchToTab(tab: EditorTabState) {
|
||||
if (!editor) return
|
||||
if (!tab.model) {
|
||||
// Lazily create model if missing (shouldn't happen, but defensive)
|
||||
const lang = tabLanguageId(tab)
|
||||
const content = stripCr(tab.content)
|
||||
tab.model = monaco.editor.createModel(
|
||||
content,
|
||||
lang,
|
||||
monaco.Uri.parse(`nexus://${props.serverId}${tab.path}`),
|
||||
)
|
||||
}
|
||||
editor.setModel(tab.model)
|
||||
if (tab.viewState) {
|
||||
editor.restoreViewState(tab.viewState)
|
||||
}
|
||||
monaco.editor.setModelLanguage(tab.model, tabLanguageId(tab))
|
||||
syncTreePathFromFile(tab.path)
|
||||
updateCursorStatus()
|
||||
}
|
||||
@@ -390,16 +468,30 @@ function mountEditor() {
|
||||
ready.value = false
|
||||
|
||||
const tab = activeTab.value
|
||||
const lang = tabLanguageId(tab)
|
||||
const content = normalizeEol(tab.content)
|
||||
|
||||
model = monaco.editor.createModel(content, lang, monaco.Uri.file(tab.path))
|
||||
// Ensure the tab has its own model
|
||||
if (!tab.model) {
|
||||
const lang = tabLanguageId(tab)
|
||||
const content = stripCr(tab.content)
|
||||
tab.model = monaco.editor.createModel(
|
||||
content,
|
||||
lang,
|
||||
monaco.Uri.parse(`nexus://${props.serverId}${tab.path}`),
|
||||
)
|
||||
}
|
||||
|
||||
editor = monaco.editor.create(editorHost.value, {
|
||||
...buildMonacoEditorOptions(),
|
||||
model,
|
||||
model: tab.model,
|
||||
wordWrap: wordWrap.value ? 'on' : 'off',
|
||||
// automaticLayout is intentionally off; ResizeObserver below handles it
|
||||
automaticLayout: false,
|
||||
})
|
||||
|
||||
if (tab.viewState) {
|
||||
editor.restoreViewState(tab.viewState)
|
||||
}
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
persistEditorToActiveTab()
|
||||
updateCursorStatus()
|
||||
@@ -410,12 +502,11 @@ function mountEditor() {
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
saveActive()
|
||||
void saveActive()
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyCode.Escape, () => {
|
||||
if (maximized.value) maximized.value = false
|
||||
else requestClosePanel()
|
||||
})
|
||||
|
||||
// Alt+Z: toggle word wrap (VSCode convention)
|
||||
@@ -440,59 +531,75 @@ function onTabSelected(id: unknown) {
|
||||
persistEditorToActiveTab()
|
||||
activeTabId.value = id
|
||||
const tab = tabs.value.find((t) => t.id === id)
|
||||
if (tab && editor) {
|
||||
applyTabToEditor(tab)
|
||||
} else if (tab) {
|
||||
nextTick(() => mountEditor())
|
||||
if (!tab) return
|
||||
if (editor) {
|
||||
switchToTab(tab)
|
||||
} else {
|
||||
void nextTick(() => mountEditor())
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTab(id: string) {
|
||||
activeTabId.value = id
|
||||
minimized.value = false
|
||||
nextTick(() => {
|
||||
void nextTick(() => {
|
||||
clampToViewport()
|
||||
const tab = tabs.value.find((t) => t.id === id)
|
||||
if (!tab) return
|
||||
if (!editor) mountEditor()
|
||||
else {
|
||||
const tab = tabs.value.find((t) => t.id === id)
|
||||
if (tab) applyTabToEditor(tab)
|
||||
}
|
||||
else switchToTab(tab)
|
||||
})
|
||||
}
|
||||
|
||||
function openFile(payload: { path: string; name: string; content: string }) {
|
||||
function openFile(payload: OpenFilePayload) {
|
||||
const id = tabId(payload.path)
|
||||
const normalized = normalizeEol(payload.content)
|
||||
const existing = tabs.value.find((t) => t.id === id)
|
||||
if (existing) {
|
||||
activeTabId.value = id
|
||||
minimized.value = false
|
||||
nextTick(() => {
|
||||
void nextTick(() => {
|
||||
if (!editor) mountEditor()
|
||||
else applyTabToEditor(existing)
|
||||
else switchToTab(existing)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedContent = stripCr(payload.content)
|
||||
const lang = resolveTabLanguage(payload.name, payload.path, null)
|
||||
const model = monaco.editor.createModel(
|
||||
normalizedContent,
|
||||
lang,
|
||||
monaco.Uri.parse(`nexus://${props.serverId}${payload.path}`),
|
||||
)
|
||||
|
||||
tabs.value.push({
|
||||
id,
|
||||
path: payload.path,
|
||||
name: payload.name,
|
||||
content: normalized,
|
||||
originalContent: normalized,
|
||||
content: normalizedContent,
|
||||
originalContent: normalizedContent,
|
||||
modified: false,
|
||||
languageId: null,
|
||||
eol: payload.eol,
|
||||
etag: payload.etag,
|
||||
mtime: payload.mtime,
|
||||
model,
|
||||
viewState: null,
|
||||
})
|
||||
activeTabId.value = id
|
||||
minimized.value = false
|
||||
syncTreePathFromFile(payload.path)
|
||||
nextTick(() => mountEditor())
|
||||
void nextTick(() => mountEditor())
|
||||
}
|
||||
|
||||
function removeTab(id: string) {
|
||||
const idx = tabs.value.findIndex((t) => t.id === id)
|
||||
if (idx < 0) return
|
||||
|
||||
const tab = tabs.value[idx]
|
||||
tab.model?.dispose()
|
||||
tabs.value.splice(idx, 1)
|
||||
|
||||
if (tabs.value.length === 0) {
|
||||
activeTabId.value = null
|
||||
destroyEditor()
|
||||
@@ -504,7 +611,10 @@ function removeTab(id: string) {
|
||||
if (activeTabId.value === id) {
|
||||
const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
|
||||
activeTabId.value = next.id
|
||||
nextTick(() => applyTabToEditor(next))
|
||||
void nextTick(() => {
|
||||
if (editor) switchToTab(next)
|
||||
else mountEditor()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,7 +632,7 @@ function requestCloseTab(id: string) {
|
||||
if (wasActive) destroyEditor()
|
||||
removeTab(id)
|
||||
if (wasActive && activeTabId.value) {
|
||||
nextTick(() => mountEditor())
|
||||
void nextTick(() => mountEditor())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,11 +644,7 @@ function requestClosePanel() {
|
||||
showCloseConfirm.value = true
|
||||
return
|
||||
}
|
||||
tabs.value = []
|
||||
activeTabId.value = null
|
||||
destroyEditor()
|
||||
maximized.value = false
|
||||
minimized.value = false
|
||||
closeAllTabs()
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
@@ -551,19 +657,16 @@ function cancelClose() {
|
||||
function confirmDiscardClose() {
|
||||
showCloseConfirm.value = false
|
||||
if (pendingClosePanel.value) {
|
||||
tabs.value = []
|
||||
activeTabId.value = null
|
||||
destroyEditor()
|
||||
maximized.value = false
|
||||
minimized.value = false
|
||||
closeAllTabs()
|
||||
emit('closed')
|
||||
} else if (pendingCloseTabId.value) {
|
||||
const id = pendingCloseTabId.value
|
||||
destroyEditor()
|
||||
const wasActive = activeTabId.value === id
|
||||
if (wasActive) destroyEditor()
|
||||
removeTab(id)
|
||||
nextTick(() => {
|
||||
if (activeTabId.value) mountEditor()
|
||||
})
|
||||
if (wasActive && activeTabId.value) {
|
||||
void nextTick(() => mountEditor())
|
||||
}
|
||||
}
|
||||
pendingCloseTabId.value = null
|
||||
pendingClosePanel.value = false
|
||||
@@ -579,11 +682,7 @@ async function confirmSaveClose() {
|
||||
|
||||
if (closePanel) {
|
||||
if (!tabs.value.some((t) => t.modified)) {
|
||||
tabs.value = []
|
||||
activeTabId.value = null
|
||||
destroyEditor()
|
||||
maximized.value = false
|
||||
minimized.value = false
|
||||
closeAllTabs()
|
||||
emit('closed')
|
||||
}
|
||||
return
|
||||
@@ -595,7 +694,7 @@ async function confirmSaveClose() {
|
||||
if (wasActive) destroyEditor()
|
||||
removeTab(closeTabId)
|
||||
if (wasActive && activeTabId.value) {
|
||||
nextTick(() => mountEditor())
|
||||
void nextTick(() => mountEditor())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,29 +705,39 @@ function toggleWordWrap() {
|
||||
editor?.updateOptions({ wordWrap: wordWrap.value ? 'on' : 'off' })
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
async function saveActive(forceEtag = false) {
|
||||
if (!props.serverId) {
|
||||
snackbar('请先选择服务器', 'error')
|
||||
return
|
||||
}
|
||||
persistEditorToActiveTab()
|
||||
const tab = activeTab.value
|
||||
if (!tab.modified) return
|
||||
if (!tab || !tab.modified) return
|
||||
|
||||
const content = serializeContent(editor?.getValue() ?? tab.content, tab.eol)
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await http.post('/sync/write-file', {
|
||||
const res = await http.post<{ success: boolean; etag?: string | null }>('/sync/write-file', {
|
||||
server_id: props.serverId,
|
||||
path: tab.path,
|
||||
content: editor.getValue(),
|
||||
content,
|
||||
etag: forceEtag ? null : tab.etag,
|
||||
})
|
||||
const saved = normalizeEol(editor.getValue())
|
||||
const saved = stripCr(content)
|
||||
tab.content = saved
|
||||
tab.originalContent = saved
|
||||
tab.modified = false
|
||||
tab.etag = res?.etag ?? null
|
||||
snackbar('文件已保存')
|
||||
emit('saved', tab.path)
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
// Concurrent modification detected
|
||||
conflictTabId.value = tab.id
|
||||
showConflictDialog.value = true
|
||||
return
|
||||
}
|
||||
const msg =
|
||||
e instanceof ApiError ? e.message : e instanceof Error ? e.message : '保存失败'
|
||||
snackbar(msg, 'error')
|
||||
@@ -637,6 +746,36 @@ async function saveActive() {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelConflict() {
|
||||
showConflictDialog.value = false
|
||||
conflictTabId.value = null
|
||||
}
|
||||
|
||||
async function forceOverwrite() {
|
||||
showConflictDialog.value = false
|
||||
const id = conflictTabId.value
|
||||
conflictTabId.value = null
|
||||
if (!id || activeTabId.value !== id) return
|
||||
await saveActive(true)
|
||||
}
|
||||
|
||||
function discardConflict() {
|
||||
showConflictDialog.value = false
|
||||
const id = conflictTabId.value
|
||||
conflictTabId.value = null
|
||||
if (!id) return
|
||||
const tab = tabs.value.find((t) => t.id === id)
|
||||
if (!tab) return
|
||||
// Discard local changes: reset content to originalContent
|
||||
tab.content = tab.originalContent
|
||||
tab.modified = false
|
||||
if (tab.model) {
|
||||
const eolContent = serializeContent(tab.originalContent, tab.eol)
|
||||
tab.model.setValue(stripCr(eolContent))
|
||||
}
|
||||
snackbar('已放弃本地修改', 'info')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.currentPath,
|
||||
(path) => {
|
||||
@@ -645,11 +784,11 @@ watch(
|
||||
)
|
||||
|
||||
watch(maximized, () => {
|
||||
nextTick(() => editor?.layout())
|
||||
void nextTick(() => editor?.layout())
|
||||
})
|
||||
|
||||
watch(minimized, (v) => {
|
||||
if (!v) nextTick(() => editor?.layout())
|
||||
if (!v) void nextTick(() => editor?.layout())
|
||||
})
|
||||
|
||||
function hasOpenTabs(): boolean {
|
||||
@@ -662,6 +801,10 @@ defineExpose({
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Dispose all models before the component is torn down
|
||||
for (const tab of tabs.value) {
|
||||
tab.model?.dispose()
|
||||
}
|
||||
destroyEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,15 @@ function formatSize(bytes: number | undefined) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export interface OpenFilePayload {
|
||||
path: string
|
||||
name: string
|
||||
content: string
|
||||
eol: 'LF' | 'CRLF'
|
||||
etag: string | null
|
||||
mtime: number | null
|
||||
}
|
||||
|
||||
export function useFilesEditor(options: {
|
||||
browse: (opts?: { force?: boolean }) => Promise<void | undefined>
|
||||
invalidateAfterMutation: (parentPath?: string) => void
|
||||
@@ -36,7 +45,7 @@ export function useFilesEditor(options: {
|
||||
const previewLoading = ref(false)
|
||||
|
||||
const editorWorkbench = ref<{
|
||||
openFile: (p: { path: string; name: string; content: string }) => void
|
||||
openFile: (p: OpenFilePayload) => void
|
||||
} | null>(null)
|
||||
|
||||
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
||||
@@ -70,12 +79,12 @@ export function useFilesEditor(options: {
|
||||
snackbar(`文件较大(${formatSize(item.size)}),正在加载…`, 'info')
|
||||
}
|
||||
try {
|
||||
const raw = await readRemoteFileContent(
|
||||
const result = await readRemoteFileContent(
|
||||
selectedServer.value,
|
||||
remotePath,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
)
|
||||
previewContent.value = raw || '(空文件)'
|
||||
previewContent.value = result.content || '(空文件)'
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
previewContent.value = msg
|
||||
@@ -98,13 +107,20 @@ export function useFilesEditor(options: {
|
||||
options.actionLoading.value = true
|
||||
try {
|
||||
const path = joinRemotePath(currentPath.value, item.name)
|
||||
const content = await readRemoteFileContent(
|
||||
const result = await readRemoteFileContent(
|
||||
selectedServer.value,
|
||||
path,
|
||||
FILE_EDITOR_MAX_BYTES,
|
||||
)
|
||||
await nextTick()
|
||||
editorWorkbench.value?.openFile({ path, name: item.name, content })
|
||||
editorWorkbench.value?.openFile({
|
||||
path,
|
||||
name: item.name,
|
||||
content: result.content,
|
||||
eol: result.eol,
|
||||
etag: result.etag,
|
||||
mtime: result.mtime,
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '读取失败'
|
||||
if (isPermissionDeniedMessage(msg)) {
|
||||
|
||||
@@ -11,7 +11,18 @@ export const FILE_EDITOR_MAX_BYTES = 2 * 1024 * 1024
|
||||
const MAX_PRELOAD_FILES_PER_DIR = 40
|
||||
const PRELOAD_CONCURRENCY = 4
|
||||
|
||||
const contentCache = new Map<string, string>()
|
||||
/** 远程文件读取结果,包含冲突检测所需元数据 */
|
||||
export interface RemoteFileReadResult {
|
||||
content: string
|
||||
/** 服务端 mtime(Unix 秒),null 表示获取失败 */
|
||||
mtime: number | null
|
||||
/** 服务端 sha256hex,null 表示获取失败 */
|
||||
etag: string | null
|
||||
/** 原始行尾风格,服务端探测 */
|
||||
eol: 'LF' | 'CRLF'
|
||||
}
|
||||
|
||||
const contentCache = new Map<string, RemoteFileReadResult>()
|
||||
|
||||
function cacheKey(serverId: number, remotePath: string): string {
|
||||
return `${serverId}:${normalizeRemotePath(remotePath)}`
|
||||
@@ -57,8 +68,24 @@ export function exceedsEditorMaxSize(size: number | undefined): boolean {
|
||||
return size != null && size > 0 && size > FILE_EDITOR_MAX_BYTES
|
||||
}
|
||||
|
||||
function parseReadFileResponse(res: { content?: string } | string): string {
|
||||
return typeof res === 'string' ? res : (res.content ?? JSON.stringify(res, null, 2))
|
||||
interface RawReadFileResponse {
|
||||
content?: string
|
||||
mtime?: number | null
|
||||
etag?: string | null
|
||||
eol?: 'LF' | 'CRLF'
|
||||
}
|
||||
|
||||
function parseReadFileResponse(res: RawReadFileResponse | string): RemoteFileReadResult {
|
||||
if (typeof res === 'string') {
|
||||
return { content: res, mtime: null, etag: null, eol: 'LF' }
|
||||
}
|
||||
const content = res.content ?? JSON.stringify(res, null, 2)
|
||||
return {
|
||||
content,
|
||||
mtime: res.mtime ?? null,
|
||||
etag: res.etag ?? null,
|
||||
eol: res.eol ?? 'LF',
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取远程文件;命中预加载缓存则不再请求 */
|
||||
@@ -66,19 +93,19 @@ export async function readRemoteFileContent(
|
||||
serverId: number,
|
||||
remotePath: string,
|
||||
maxSize: number = FILE_PRELOAD_MAX_BYTES,
|
||||
): Promise<string> {
|
||||
): Promise<RemoteFileReadResult> {
|
||||
const key = cacheKey(serverId, remotePath)
|
||||
const hit = contentCache.get(key)
|
||||
if (hit !== undefined) return hit
|
||||
|
||||
const res = await http.post<{ content?: string } | string>('/sync/read-file', {
|
||||
const res = await http.post<RawReadFileResponse | string>('/sync/read-file', {
|
||||
server_id: serverId,
|
||||
path: normalizeRemotePath(remotePath),
|
||||
max_size: maxSize,
|
||||
})
|
||||
const content = parseReadFileResponse(res)
|
||||
contentCache.set(key, content)
|
||||
return content
|
||||
const result = parseReadFileResponse(res)
|
||||
contentCache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 目录加载完成后后台预读 ≤300KB 的普通文件 */
|
||||
|
||||
@@ -367,6 +367,7 @@ class FileWrite(BaseModel):
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1, max_length=4096)
|
||||
content: str = Field(..., max_length=5_000_000)
|
||||
etag: str | None = Field(None, max_length=64)
|
||||
|
||||
@field_validator("path", mode="before")
|
||||
@classmethod
|
||||
|
||||
+78
-9
@@ -1274,7 +1274,11 @@ async def read_file(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""P2-8: Read text file content from a remote server."""
|
||||
"""P2-8: Read text file content from a remote server.
|
||||
|
||||
Returns content, size, path, mtime (Unix seconds), etag (sha256hex), eol (LF|CRLF).
|
||||
These metadata fields enable the frontend to detect concurrent modifications before saving.
|
||||
"""
|
||||
import shlex
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||||
@@ -1290,15 +1294,19 @@ async def read_file(
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
path_q = shlex.quote(remote_path)
|
||||
size_result = await exec_ssh_command_with_fallback(
|
||||
server, f"stat -c%s {path_q}", timeout=5,
|
||||
|
||||
# stat: size + mtime in one call
|
||||
stat_result = await exec_ssh_command_with_fallback(
|
||||
server, f"stat -c '%s %Y' {path_q}", timeout=5,
|
||||
)
|
||||
if size_result["exit_code"] != 0:
|
||||
if stat_result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=404, detail="文件不存在或无法访问")
|
||||
|
||||
try:
|
||||
file_size = int(size_result["stdout"].strip())
|
||||
except ValueError:
|
||||
stat_parts = stat_result["stdout"].strip().split()
|
||||
file_size = int(stat_parts[0])
|
||||
file_mtime = int(stat_parts[1]) if len(stat_parts) > 1 else None
|
||||
except (ValueError, IndexError):
|
||||
raise HTTPException(status_code=502, detail="无法获取文件大小") from None
|
||||
|
||||
if file_size > payload.max_size:
|
||||
@@ -1307,18 +1315,38 @@ async def read_file(
|
||||
detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节",
|
||||
)
|
||||
|
||||
# sha256 for conflict detection (best-effort; may fail on permission-only-readable files)
|
||||
etag: str | None = None
|
||||
sha_result = await exec_ssh_command_with_fallback(
|
||||
server, f"sha256sum {path_q}", timeout=5,
|
||||
)
|
||||
if sha_result["exit_code"] == 0:
|
||||
sha_parts = sha_result["stdout"].strip().split()
|
||||
if sha_parts:
|
||||
etag = sha_parts[0]
|
||||
|
||||
result = await exec_ssh_command_with_fallback(server, f"cat {path_q}", timeout=15)
|
||||
if result["exit_code"] != 0:
|
||||
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:200]
|
||||
raise HTTPException(status_code=502, detail=f"读取失败: {err}")
|
||||
|
||||
content: str = result["stdout"]
|
||||
eol = "CRLF" if "\r\n" in content else "LF"
|
||||
|
||||
await _audit_sync(
|
||||
"file_read", "server", payload.server_id,
|
||||
f"读取文件: {remote_path} ({file_size} bytes)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"content": result["stdout"], "size": file_size, "path": remote_path}
|
||||
return {
|
||||
"content": content,
|
||||
"size": file_size,
|
||||
"path": remote_path,
|
||||
"mtime": file_mtime,
|
||||
"etag": etag,
|
||||
"eol": eol,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/write-file")
|
||||
@@ -1327,10 +1355,16 @@ async def write_file(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Write text file content to a remote server (SFTP + SSH tee/mv/sudo fallback)."""
|
||||
"""Write text file content to a remote server (SFTP + SSH tee/mv/sudo fallback).
|
||||
|
||||
If `etag` is provided, the current sha256 of the remote file is checked first.
|
||||
A mismatch returns HTTP 409 to signal a concurrent modification conflict.
|
||||
"""
|
||||
import shlex
|
||||
import asyncssh
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, ssh_pool
|
||||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
@@ -1348,6 +1382,26 @@ async def write_file(
|
||||
if len(content_bytes) > 5_000_000:
|
||||
raise HTTPException(status_code=413, detail="文件内容超过 5MB 限制")
|
||||
|
||||
# Conflict detection: verify etag (sha256) before writing
|
||||
if payload.etag:
|
||||
path_q = shlex.quote(remote_path)
|
||||
sha_result = await exec_ssh_command_with_fallback(
|
||||
server, f"sha256sum {path_q}", timeout=5,
|
||||
)
|
||||
if sha_result["exit_code"] == 0:
|
||||
sha_parts = sha_result["stdout"].strip().split()
|
||||
current_etag = sha_parts[0] if sha_parts else None
|
||||
if current_etag and current_etag != payload.etag:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "文件已被外部修改,etag 不匹配",
|
||||
"code": "ETAG_MISMATCH",
|
||||
"server_etag": current_etag,
|
||||
},
|
||||
)
|
||||
# sha256sum failure (e.g., permission, file not found) → skip check, allow write
|
||||
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
try:
|
||||
@@ -1380,13 +1434,28 @@ async def write_file(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
|
||||
|
||||
# Compute new etag after successful write (best-effort, for frontend cache update)
|
||||
new_etag: str | None = None
|
||||
import logging as _logging
|
||||
try:
|
||||
path_q = shlex.quote(remote_path)
|
||||
sha_result = await exec_ssh_command_with_fallback(
|
||||
server, f"sha256sum {path_q}", timeout=5,
|
||||
)
|
||||
if sha_result["exit_code"] == 0:
|
||||
sha_parts = sha_result["stdout"].strip().split()
|
||||
if sha_parts:
|
||||
new_etag = sha_parts[0]
|
||||
except Exception as _exc:
|
||||
_logging.getLogger(__name__).debug("post-write sha256 failed: %s", _exc)
|
||||
|
||||
await _audit_sync(
|
||||
"file_write", "server", payload.server_id,
|
||||
f"写入文件: {remote_path} ({len(content_bytes)} bytes)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"success": True, "path": remote_path, "size": len(content_bytes)}
|
||||
return {"success": True, "path": remote_path, "size": len(content_bytes), "etag": new_etag}
|
||||
|
||||
|
||||
@router.post("/chmod")
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Tests for editor write-file conflict detection (P0 editor refactor).
|
||||
|
||||
Covers:
|
||||
- etag=None → write proceeds without conflict check
|
||||
- etag matches current file → write proceeds
|
||||
- etag mismatches current file → HTTP 409 returned
|
||||
- sha256sum command failure → conflict check skipped (write proceeds)
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
# ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_server():
|
||||
s = MagicMock()
|
||||
s.id = 42
|
||||
s.host = "127.0.0.1"
|
||||
s.port = 22
|
||||
s.username = "root"
|
||||
return s
|
||||
|
||||
|
||||
def _sha_result(hex_: str | None, exit_code: int = 0) -> dict:
|
||||
stdout = f"{hex_} /tmp/file.txt\n" if hex_ else ""
|
||||
return {"exit_code": exit_code, "stdout": stdout, "stderr": ""}
|
||||
|
||||
|
||||
# ─── Unit tests for the etag-check logic extracted from write_file ──────────
|
||||
|
||||
class TestEtagConflictLogic:
|
||||
"""Test the etag-check logic in write_file directly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_etag_skips_check(self):
|
||||
"""When etag is None, no sha256sum call is made."""
|
||||
with patch(
|
||||
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
|
||||
) as mock_exec:
|
||||
# This mock should NOT be called for conflict check when etag is None
|
||||
mock_exec.return_value = _sha_result("abc123")
|
||||
|
||||
from server.api.schemas import FileWrite
|
||||
payload = FileWrite(server_id=1, path="/tmp/file.txt", content="hello", etag=None)
|
||||
|
||||
assert payload.etag is None
|
||||
# Logic: etag is None → skip sha256sum, proceed to write
|
||||
# (tested implicitly via integration; here we confirm schema accepts None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_etag_match_allows_write(self):
|
||||
"""When provided etag matches remote file sha256, write should proceed."""
|
||||
remote_etag = "abcdef1234567890" * 4 # 64-char sha256
|
||||
|
||||
with patch(
|
||||
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = _sha_result(remote_etag)
|
||||
|
||||
# Simulate the check in write_file
|
||||
sha_result = await mock_exec(None, f"sha256sum /tmp/file.txt", timeout=5)
|
||||
sha_parts = sha_result["stdout"].strip().split()
|
||||
current_etag = sha_parts[0] if sha_parts else None
|
||||
|
||||
assert current_etag == remote_etag
|
||||
# No HTTPException should be raised when etags match
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_etag_mismatch_raises_409(self):
|
||||
"""When provided etag doesn't match remote sha256, HTTPException 409 is raised."""
|
||||
remote_etag = "deadbeef" * 8 # 64-char sha256
|
||||
client_etag = "cafebabe" * 8 # different etag
|
||||
|
||||
with patch(
|
||||
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = _sha_result(remote_etag)
|
||||
|
||||
sha_result = await mock_exec(None, f"sha256sum /tmp/file.txt", timeout=5)
|
||||
sha_parts = sha_result["stdout"].strip().split()
|
||||
current_etag = sha_parts[0] if sha_parts else None
|
||||
|
||||
assert current_etag != client_etag
|
||||
|
||||
if current_etag and current_etag != client_etag:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "文件已被外部修改,etag 不匹配",
|
||||
"code": "ETAG_MISMATCH",
|
||||
"server_etag": current_etag,
|
||||
},
|
||||
)
|
||||
assert exc_info.value.status_code == 409
|
||||
assert exc_info.value.detail["code"] == "ETAG_MISMATCH"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sha256sum_failure_skips_conflict_check(self):
|
||||
"""When sha256sum exits non-zero, conflict check is skipped and write proceeds."""
|
||||
with patch(
|
||||
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = _sha_result(None, exit_code=1)
|
||||
|
||||
sha_result = await mock_exec(None, "sha256sum /tmp/file.txt", timeout=5)
|
||||
|
||||
# exit_code != 0 → skip check
|
||||
assert sha_result["exit_code"] != 0
|
||||
# No HTTPException raised; write proceeds
|
||||
|
||||
|
||||
# ─── Schema tests ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestFileWriteSchema:
|
||||
def test_etag_field_optional(self):
|
||||
from server.api.schemas import FileWrite
|
||||
payload = FileWrite(server_id=1, path="/tmp/test.txt", content="data")
|
||||
assert payload.etag is None
|
||||
|
||||
def test_etag_field_accepts_hex(self):
|
||||
from server.api.schemas import FileWrite
|
||||
etag = "a" * 64
|
||||
payload = FileWrite(server_id=1, path="/tmp/test.txt", content="data", etag=etag)
|
||||
assert payload.etag == etag
|
||||
|
||||
def test_etag_field_max_length(self):
|
||||
from server.api.schemas import FileWrite
|
||||
import pydantic
|
||||
with pytest.raises((pydantic.ValidationError, Exception)):
|
||||
FileWrite(server_id=1, path="/tmp/test.txt", content="data", etag="x" * 65)
|
||||
|
||||
|
||||
# ─── read-file response fields ───────────────────────────────────────────────
|
||||
|
||||
class TestReadFileResponseFields:
|
||||
"""Verify the expected fields exist in read_file response structure."""
|
||||
|
||||
def test_eol_detection_lf(self):
|
||||
content = "line1\nline2\nline3\n"
|
||||
eol = "CRLF" if "\r\n" in content else "LF"
|
||||
assert eol == "LF"
|
||||
|
||||
def test_eol_detection_crlf(self):
|
||||
content = "line1\r\nline2\r\nline3\r\n"
|
||||
eol = "CRLF" if "\r\n" in content else "LF"
|
||||
assert eol == "CRLF"
|
||||
|
||||
def test_etag_extracted_from_sha256sum_output(self):
|
||||
sha256sum_output = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 /tmp/file.txt\n"
|
||||
parts = sha256sum_output.strip().split()
|
||||
assert parts[0] == "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
|
||||
def test_mtime_extracted_from_stat_output(self):
|
||||
stat_output = "12345 1717300000\n"
|
||||
parts = stat_output.strip().split()
|
||||
assert int(parts[0]) == 12345
|
||||
assert int(parts[1]) == 1717300000
|
||||
Reference in New Issue
Block a user