dsh-vscode-mode 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/lib/client.js +906 -395
- package/lib/client.js.map +1 -1
- package/lib/index.js +1594 -607
- package/lib/index.js.map +1 -1
- package/package.json +7 -7
- package/skills/dsh-vscodemode-mcp/SKILL.md +9 -3
- package/src/archiveDb.ts +476 -0
- package/src/capture.ts +8 -1
- package/src/client/diffDockStore.ts +18 -0
- package/src/client/mcpForm.ts +79 -0
- package/src/client/mcpState.ts +179 -0
- package/src/client/monaco/diffRender.ts +3 -0
- package/src/client/state/regions.ts +21 -1
- package/src/client/styles/editor.css +12 -0
- package/src/client/styles/mcp.css +6 -3
- package/src/client/ui/ConversationDiffDock.ts +2 -2
- package/src/client/ui/DiffLauncher.ts +76 -17
- package/src/client/ui/EditorView.ts +90 -7
- package/src/client/ui/McpSettings.ts +174 -137
- package/src/dshVersion.ts +4 -3
- package/src/index.ts +2 -0
- package/src/mcp.ts +131 -23
- package/src/mcpIsolation.ts +2 -1
- package/src/mcpProject.ts +74 -14
- package/src/mcpRuntime.ts +18 -3
- package/src/paths.ts +11 -0
- package/src/perf.ts +17 -24
- package/src/revert.ts +93 -1
- package/src/rpc.ts +243 -68
- package/src/shared/diff.ts +69 -20
- package/src/shared/mcpStatus.ts +30 -0
- package/src/shared/rpc.ts +10 -1
- package/src/store.ts +154 -55
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
|
|
5
5
|
"icon": "assets/icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"@vscode/ripgrep": "^1.18.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
|
|
52
|
-
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
|
|
53
|
-
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
|
|
55
|
-
"@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
|
|
56
|
-
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
|
|
57
57
|
"cordis": ">=4.0.0-rc <5",
|
|
58
58
|
"react": "^18.2.0",
|
|
59
59
|
"react-dom": "^18.2.0",
|
|
@@ -29,6 +29,10 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
|
|
|
29
29
|
|
|
30
30
|
禁用、删除、配置变更、刷新重连及 agent/插件卸载都会释放相应 fiber 和连接。仅刷新未变化的项目列表不会重复连接。主机代码更新需要重启 DSH 才能加载新实现。
|
|
31
31
|
|
|
32
|
+
管理页首次加载配置,之后仅在 MCP 页签可见且文档可见时每 2.5 秒读取 `mcp.snapshot`;单次读取 10 秒截止,不重叠、不在卸载后回写。快照只读运行台账,不读项目文件、不 reconcile、不额外建连。外部文件变更通过显式刷新/重新打开设置或新 agent 加载;快照不是文件监听器。配置写操作串行保护读取—修改—写入与跨作用域命名保留。
|
|
33
|
+
|
|
34
|
+
表单 stdio 参数采用 JSON `string[]` 或每行一个完整参数,不按空格/引号做 shell 拆分;含空参数或内嵌换行使用 JSON。HTTP 请求头每行 `key=value`,保留值内的等号,拒绝非法/重复(忽略大小写)键。失败保存保持表单,忙时禁止重复动作。
|
|
35
|
+
|
|
32
36
|
## 3. 项目配置
|
|
33
37
|
|
|
34
38
|
`.mcp.json` 示例:
|
|
@@ -57,7 +61,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
|
|
|
57
61
|
- `disabled: true` 保持配置但不创建连接;保存表单会恢复启用。重启和 reconcile 都尊重 `disabled`。
|
|
58
62
|
- 保存保留文档顶层、其他服务器及当前服务器的未知字段和未编辑高级参数;切换传输方式会移除旧传输专属字段。
|
|
59
63
|
- `env` / `headers` 在返回值中使用 `••••••` 脱敏。原键的未修改掩码保存时恢复真实值;新键只有掩码而没有原值时会拒绝保存,需填写凭据。
|
|
60
|
-
-
|
|
64
|
+
- 配置不存在只认结构化错误码:官方 fs 的 `FS_NOT_FOUND` 与兼容旧后端的 `ENOENT`;缺失的可选配置按空服务处理,不写空文件,不阻断其他项目的重名检查。权限、IO、JSON 或结构错误会报告 `fileError`,保留最后有效配置与活动连接,不覆盖损坏文件。
|
|
61
65
|
|
|
62
66
|
全局配置继续使用官方 loader entry:
|
|
63
67
|
|
|
@@ -80,6 +84,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
|
|
|
80
84
|
| 方法 | 入参 | 成功返回字段 |
|
|
81
85
|
|---|---|---|
|
|
82
86
|
| `mcp.list` | `{}` | `servers` |
|
|
87
|
+
| `mcp.snapshot` | `{}` | `servers`, `projects`(只读运行快照) |
|
|
83
88
|
| `mcp.save` | `{ config }` | `server` |
|
|
84
89
|
| `mcp.remove` | `{ id }` | 无 |
|
|
85
90
|
| `mcp.toggle` | `{ id, enabled }` | `server` |
|
|
@@ -97,6 +102,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
|
|
|
97
102
|
- `serverName` 使用 `/^[A-Za-z0-9_-]{1,32}$/`;允许下划线,与技能名规则不同。
|
|
98
103
|
- VSCodeMode 管理的全局和所有项目配置之间名称唯一,包括未启用和没有活动 agent 的配置。官方 MCP 还会拒绝同一个 agent scope 中的重复名称。
|
|
99
104
|
- 工具名是 `mcp__<serverName>__<原始工具名>`。共享资源工具通过 `arguments.server` 选择服务器。
|
|
105
|
+
- 不同服务名称不得以 `__` 形成前缀交叠,例如 `alpha` 与 `alpha__nested`:原始工具名也允许 `__`,官方编码不能唯一反解,不能靠最长前缀猜归属。保存与 reconcile 双向检查全局/所有项目(含禁用项);历史歧义工具执行时 fail-closed 且不计入任何服务。独立含 `__` 的名称仍合法。
|
|
100
106
|
- 项目稳定 id 仍是 `vsm-mcp.<workspaceHash>.<serverName>`,用于 UI/RPC 身份;0.13 起不再代表全局 loader entry。
|
|
101
107
|
- 启动迁移会先删除旧的 `vsm-mcp.*` 和 `vsm-mcp:*` 全局项目条目及其连接,再在 agent scope 挂载。不要手动将项目条目改成其他 id。
|
|
102
108
|
- 项目写操作只接受已注册 workspace,目标固定为该根目录的 `.mcp.json`。
|
|
@@ -120,10 +126,10 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
|
|
|
120
126
|
|
|
121
127
|
| 状态/现象 | 含义与处理 |
|
|
122
128
|
|---|---|
|
|
123
|
-
| `configured` |
|
|
129
|
+
| `configured` | 中性色;配置已保存,没有已挂载实例,打开匹配工作区会话后挂载 |
|
|
124
130
|
| `disabled` | 配置已停用,不建立项目连接 |
|
|
125
131
|
| `connecting` | 官方插件还在等待启动完成;检查命令、cwd、URL 和握手 |
|
|
126
|
-
| `unverified` | fiber
|
|
132
|
+
| `unverified` | 中性色;fiber 已激活,但官方没有公开连接状态证据,不能宣称已连接;右侧绿色开关只表示启用 |
|
|
127
133
|
| `error` | 插件启动或挂载失败;读 `error` 与 Host 日志 |
|
|
128
134
|
| `instanceCount: N` | N 个挂载的官方插件 fiber,**不等于 N 个确认存活的网络连接** |
|
|
129
135
|
| `toolCount: 0` | 可能是合法的纯资源服务器,也可能启动失败或工具同步失败;结合资源调用和日志判断 |
|
package/src/archiveDb.ts
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区差异归档 V2:SQLite 事务追加与按需读取;旧 JSON 只读留存,不参与热路径。
|
|
3
|
+
* 数据库位于 DSH home 的 data/,不会被缓存清理器删除。
|
|
4
|
+
* 作者 ddj 2026-09-30
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync } from 'node:fs'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
10
|
+
import type { ArchiveBatch, ArchiveEntry, ArchiveRecord, ArchiveSummary, DiffRecord, SidecarData } from './shared/types.js'
|
|
11
|
+
import { archiveDbFile } from './paths.js'
|
|
12
|
+
|
|
13
|
+
export type RevertState = 'started' | 'applied'
|
|
14
|
+
export interface ActiveChange { upserts: DiffRecord[]; removed: string[] }
|
|
15
|
+
export interface ArchivePage { entries: ArchiveEntry[]; nextCursor: string | null }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 数据库行中 null 批次用独立字符串键,避免 SQLite UNIQUE 对 NULL 不去重。
|
|
19
|
+
* @author ddj 2026年09月30号
|
|
20
|
+
* @param batch 原始批次
|
|
21
|
+
* @returns 可用于唯一约束的字符串键
|
|
22
|
+
*/
|
|
23
|
+
const batchKey = (batch: number | null): string => batch === null ? 'null' : 'n' + batch
|
|
24
|
+
|
|
25
|
+
/** 仅由本插件打开的工作区连接;插件卸载时 closeArchiveDbs 关闭。 */
|
|
26
|
+
const connections = new Map<string, DatabaseSync>()
|
|
27
|
+
|
|
28
|
+
type Row = Record<string, string | number | null>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 打开且核验单工作区数据库;已有异常库绝不重置成空库。
|
|
32
|
+
* @author ddj 2026年09月30号
|
|
33
|
+
* @param cwd 工作区绝对路径
|
|
34
|
+
* @returns 已缓存连接
|
|
35
|
+
*/
|
|
36
|
+
function dbFor(cwd: string): DatabaseSync {
|
|
37
|
+
const existing = connections.get(cwd)
|
|
38
|
+
if (existing) return existing
|
|
39
|
+
const file = archiveDbFile(cwd)
|
|
40
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
41
|
+
const db = new DatabaseSync(file, { timeout: 2000 })
|
|
42
|
+
try {
|
|
43
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=2000')
|
|
44
|
+
db.exec('CREATE TABLE IF NOT EXISTS metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
|
45
|
+
const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row | undefined
|
|
46
|
+
const workspace = db.prepare("SELECT value FROM metadata WHERE name='cwd'").get() as Row | undefined
|
|
47
|
+
if ((version && version.value !== '2' && version.value !== '3') || (workspace && workspace.value !== cwd)) throw new Error('归档数据库 schema/工作区不匹配')
|
|
48
|
+
if (!!version !== !!workspace) throw new Error('归档数据库元数据不完整')
|
|
49
|
+
if (!version) {
|
|
50
|
+
const other = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name<>'metadata' LIMIT 1").get()
|
|
51
|
+
if (other) throw new Error('归档数据库缺失元数据且已有数据表')
|
|
52
|
+
}
|
|
53
|
+
db.exec(`CREATE TABLE IF NOT EXISTS batches (
|
|
54
|
+
id INTEGER PRIMARY KEY, cwd TEXT NOT NULL, path TEXT NOT NULL, batch_key TEXT NOT NULL,
|
|
55
|
+
batch INTEGER, at TEXT NOT NULL, last_at TEXT, reason TEXT,
|
|
56
|
+
accepted INTEGER NOT NULL DEFAULT 0, rejected INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
pending INTEGER NOT NULL DEFAULT 0, superseded INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
n_records INTEGER NOT NULL DEFAULT 0, UNIQUE(cwd,path,batch_key)
|
|
59
|
+
);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
61
|
+
batch_id INTEGER NOT NULL REFERENCES batches(id), call_id TEXT NOT NULL,
|
|
62
|
+
payload TEXT NOT NULL, accepted INTEGER NOT NULL, rejected INTEGER NOT NULL,
|
|
63
|
+
pending INTEGER NOT NULL, superseded INTEGER NOT NULL,
|
|
64
|
+
PRIMARY KEY(batch_id,call_id)
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS archive_by_path ON batches(cwd,path,batch);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS archive_by_call ON records(call_id);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS revert_intents (
|
|
69
|
+
call_id TEXT NOT NULL, scope TEXT NOT NULL, hunk_index INTEGER NOT NULL,
|
|
70
|
+
state TEXT NOT NULL CHECK(state IN ('started','applied')),
|
|
71
|
+
PRIMARY KEY(call_id,scope,hunk_index)
|
|
72
|
+
)`)
|
|
73
|
+
if (!version) {
|
|
74
|
+
db.exec('BEGIN IMMEDIATE')
|
|
75
|
+
try {
|
|
76
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('schema','2')").run()
|
|
77
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('cwd',?)").run(cwd)
|
|
78
|
+
db.exec('COMMIT')
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (version?.value === '3') {
|
|
85
|
+
const active = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='active_records'").get()
|
|
86
|
+
const source = db.prepare("SELECT value FROM metadata WHERE name='active_source'").get()
|
|
87
|
+
if (!active || !source) throw new Error('活跃记录数据库元数据不完整')
|
|
88
|
+
}
|
|
89
|
+
connections.set(cwd, db)
|
|
90
|
+
return db
|
|
91
|
+
} catch (error) {
|
|
92
|
+
db.close()
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 只在隔离的切换窗口导入旧 sidecar;不接受模糊空库或解析损坏的输入。
|
|
99
|
+
* @author ddj 2026年09月30号
|
|
100
|
+
* @param cwd 工作区
|
|
101
|
+
* @param source 旧文件原文(null 仅表示明确不存在)
|
|
102
|
+
* @param confirm 再读源文件原文,防止迁移途中旧 Host 改写
|
|
103
|
+
* @returns 活跃记录 Map
|
|
104
|
+
*/
|
|
105
|
+
export async function migrateActive(cwd: string, source: string | null, confirm: () => Promise<string | null>): Promise<Map<string, DiffRecord>> {
|
|
106
|
+
const db = dbFor(cwd)
|
|
107
|
+
const version = String((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value)
|
|
108
|
+
if (version === '3') return readActive(cwd)
|
|
109
|
+
const digest = source === null ? 'absent' : createHash('sha256').update(source).digest('hex')
|
|
110
|
+
let recs: Record<string, DiffRecord> = {}
|
|
111
|
+
if (source !== null) {
|
|
112
|
+
let parsed: SidecarData
|
|
113
|
+
try { parsed = JSON.parse(source) as SidecarData } catch { throw new Error('旧活跃 sidecar 损坏,停止迁移') }
|
|
114
|
+
if (parsed?.version !== 2 || !parsed.workspaces || typeof parsed.workspaces !== 'object' || Array.isArray(parsed.workspaces)) throw new Error('旧活跃 sidecar 格式无效')
|
|
115
|
+
const workspace = parsed.workspaces[cwd]
|
|
116
|
+
if (workspace && (!workspace.records || typeof workspace.records !== 'object' || Array.isArray(workspace.records))) throw new Error('旧工作区记录格式无效')
|
|
117
|
+
recs = workspace?.records ?? {}
|
|
118
|
+
for (const [key, rec] of Object.entries(recs)) {
|
|
119
|
+
if (!rec || rec.callId !== key || typeof rec.path !== 'string' || !Array.isArray(rec.hunks)
|
|
120
|
+
|| !rec.decisions || !Array.isArray(rec.decisions.perHunk) || !Number.isInteger(rec.batch)
|
|
121
|
+
|| typeof rec.at !== 'string') throw new Error('旧活跃记录损坏:' + key)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (await confirm() !== source) throw new Error('迁移期间旧活跃 sidecar 被修改')
|
|
125
|
+
if (activeReady(cwd)) return readActive(cwd)
|
|
126
|
+
db.exec('BEGIN IMMEDIATE')
|
|
127
|
+
try {
|
|
128
|
+
db.exec(`CREATE TABLE active_records(call_id TEXT PRIMARY KEY, payload TEXT NOT NULL,
|
|
129
|
+
path TEXT NOT NULL, archived INTEGER NOT NULL, pending INTEGER NOT NULL, at TEXT NOT NULL);
|
|
130
|
+
CREATE INDEX active_by_path ON active_records(path,archived)`)
|
|
131
|
+
const add = db.prepare('INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)')
|
|
132
|
+
for (const rec of Object.values(recs)) add.run(rec.callId, JSON.stringify(rec), rec.path, rec.archived ? 1 : 0, rec.decisions?.perHunk?.filter((d) => d === 'pending').length ?? 0, rec.at)
|
|
133
|
+
if (await confirm() !== source) throw new Error('迁移期间旧活跃 sidecar 被修改')
|
|
134
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('active_source',?)").run(digest)
|
|
135
|
+
db.prepare("UPDATE metadata SET value='3' WHERE name='schema'").run()
|
|
136
|
+
db.exec('COMMIT')
|
|
137
|
+
return new Map(Object.values(recs).map((rec) => [rec.callId, rec]))
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
140
|
+
throw error
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 判断已核验的工作区是否完成活跃表迁移,不写入工作区记录。
|
|
146
|
+
* @author ddj 2026年09月30号
|
|
147
|
+
* @param cwd 工作区
|
|
148
|
+
* @returns 迁移完成返回 true
|
|
149
|
+
*/
|
|
150
|
+
export function activeReady(cwd: string): boolean {
|
|
151
|
+
return (dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value === '3'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 对旧文件原文核对迁移标记,旧 Host 若继续写旧 sidecar 则拒绝启用新活跃库。
|
|
156
|
+
* @author ddj 2026年09月30号
|
|
157
|
+
* @param cwd 工作区
|
|
158
|
+
* @param source 当前遗留文件原文
|
|
159
|
+
*/
|
|
160
|
+
export function checkActiveSource(cwd: string, source: string | null): void {
|
|
161
|
+
const db = dbFor(cwd)
|
|
162
|
+
const row = db.prepare("SELECT value FROM metadata WHERE name='active_source'").get() as Row | undefined
|
|
163
|
+
if (!row) throw new Error('活跃记录缺少迁移标记')
|
|
164
|
+
const digest = source === null ? 'absent' : createHash('sha256').update(source).digest('hex')
|
|
165
|
+
if (row.value !== digest) throw new Error('旧活跃 sidecar 在迁移后发生变化,请停止操作并人工核对')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 只读当前工作区活跃表;未迁移时禁止回退到空库。
|
|
170
|
+
* @author ddj 2026年09月30号
|
|
171
|
+
* @param cwd 工作区
|
|
172
|
+
* @returns 完整有限记录桶
|
|
173
|
+
*/
|
|
174
|
+
export function readActive(cwd: string): Map<string, DiffRecord> {
|
|
175
|
+
const db = dbFor(cwd)
|
|
176
|
+
if ((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value !== '3') throw new Error('工作区活跃记录未迁移')
|
|
177
|
+
const rows = db.prepare('SELECT payload FROM active_records ORDER BY rowid').all() as Row[]
|
|
178
|
+
const recs = rows.map((row) => JSON.parse(String(row.payload)) as DiffRecord)
|
|
179
|
+
return new Map(recs.map((rec) => [rec.callId, rec]))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 单次 SQLite 事务提交活跃行变化及可选归档,绝不重写整个记录桶。
|
|
184
|
+
* @author ddj 2026年09月30号
|
|
185
|
+
* @param cwd 工作区
|
|
186
|
+
* @param changes 明确新增、更新和移除的记录
|
|
187
|
+
* @param entries 可选本次归档批次
|
|
188
|
+
* @returns 新增归档批次数
|
|
189
|
+
*/
|
|
190
|
+
export function commitActive(cwd: string, changes: ActiveChange, entries: ArchiveBatch[] = []): number {
|
|
191
|
+
const db = dbFor(cwd)
|
|
192
|
+
if ((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value !== '3') throw new Error('工作区活跃记录未迁移')
|
|
193
|
+
const upsert = db.prepare(`INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)
|
|
194
|
+
ON CONFLICT(call_id) DO UPDATE SET payload=excluded.payload,path=excluded.path,
|
|
195
|
+
archived=excluded.archived,pending=excluded.pending,at=excluded.at`)
|
|
196
|
+
const remove = db.prepare('DELETE FROM active_records WHERE call_id=?')
|
|
197
|
+
db.exec('BEGIN IMMEDIATE')
|
|
198
|
+
try {
|
|
199
|
+
const added = entries.length ? insertArchive(db, cwd, entries) : 0
|
|
200
|
+
for (const rec of changes.upserts) {
|
|
201
|
+
const pending = rec.decisions?.perHunk?.filter((decision) => decision === 'pending').length ?? 0
|
|
202
|
+
upsert.run(rec.callId, JSON.stringify(rec), rec.path, rec.archived ? 1 : 0, pending, rec.at)
|
|
203
|
+
}
|
|
204
|
+
for (const callId of changes.removed) remove.run(callId)
|
|
205
|
+
db.exec('COMMIT')
|
|
206
|
+
return added
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 轻量活跃摘要;旧 JSON 保留为迁移前快照而不参与统计。
|
|
215
|
+
* @author ddj 2026年09月30号
|
|
216
|
+
* @param cwd 工作区
|
|
217
|
+
* @returns 活跃条数和每文件待处理计数
|
|
218
|
+
*/
|
|
219
|
+
export function activeSummary(cwd: string): { active: number; pendingByFile: { path: string; pending: number }[] } {
|
|
220
|
+
const db = dbFor(cwd)
|
|
221
|
+
if ((db.prepare("SELECT value FROM metadata WHERE name='schema'").get() as Row).value !== '3') throw new Error('工作区活跃记录未迁移')
|
|
222
|
+
const active = Number((db.prepare('SELECT count(*) AS n FROM active_records WHERE archived=0').get() as Row).n)
|
|
223
|
+
const rows = db.prepare('SELECT path, sum(pending) AS pending FROM active_records WHERE archived=0 AND pending>0 GROUP BY path ORDER BY pending DESC').all() as Row[]
|
|
224
|
+
return { active, pendingByFile: rows.map((row) => ({ path: String(row.path), pending: Number(row.pending) })) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 对一批同路径记录做事务内去重追加,返回成功插入的批次数。
|
|
229
|
+
* @author ddj 2026年09月30号
|
|
230
|
+
* @param cwd 工作区
|
|
231
|
+
* @param entries 已按旧语义构造的归档批次
|
|
232
|
+
* @returns 新增批次数
|
|
233
|
+
*/
|
|
234
|
+
export function appendArchive(cwd: string, entries: ArchiveBatch[]): number {
|
|
235
|
+
if (!entries.length) return 0
|
|
236
|
+
const db = dbFor(cwd)
|
|
237
|
+
db.exec('BEGIN IMMEDIATE')
|
|
238
|
+
try {
|
|
239
|
+
const added = insertArchive(db, cwd, entries)
|
|
240
|
+
db.exec('COMMIT')
|
|
241
|
+
return added
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 在调用方事务内追加归档批次;归档与活跃行可共用一次提交。
|
|
250
|
+
* @author ddj 2026年09月30号
|
|
251
|
+
* @param db 当前工作区连接
|
|
252
|
+
* @param cwd 工作区
|
|
253
|
+
* @param entries 待归档条目
|
|
254
|
+
* @returns 新增批次数
|
|
255
|
+
*/
|
|
256
|
+
function insertArchive(db: DatabaseSync, cwd: string, entries: ArchiveBatch[]): number {
|
|
257
|
+
const get = db.prepare('SELECT id FROM batches WHERE cwd=? AND path=? AND batch_key=?')
|
|
258
|
+
const add = db.prepare('INSERT INTO batches(cwd,path,batch_key,batch,at,last_at,reason) VALUES(?,?,?,?,?,?,?)')
|
|
259
|
+
const update = db.prepare('UPDATE batches SET last_at=?, reason=? WHERE id=?')
|
|
260
|
+
const insert = db.prepare('INSERT OR IGNORE INTO records(batch_id,call_id,payload,accepted,rejected,pending,superseded) VALUES(?,?,?,?,?,?,?)')
|
|
261
|
+
const tally = db.prepare(`UPDATE batches SET n_records=n_records+1, accepted=accepted+?, rejected=rejected+?,
|
|
262
|
+
pending=pending+?, superseded=superseded+? WHERE id=?`)
|
|
263
|
+
let added = 0
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (entry.cwd !== cwd) throw new Error('归档批次工作区不匹配')
|
|
266
|
+
const key = batchKey(entry.batch)
|
|
267
|
+
const found = get.get(cwd, entry.path, key) as Row | undefined
|
|
268
|
+
const id = found ? Number(found.id) : Number(add.run(cwd, entry.path, key, entry.batch, entry.at, entry.lastAt ?? null, entry.reason).lastInsertRowid)
|
|
269
|
+
if (found) update.run(entry.at, entry.reason, id)
|
|
270
|
+
else added++
|
|
271
|
+
for (const record of entry.records) {
|
|
272
|
+
const sum = record.summary
|
|
273
|
+
const result = insert.run(id, record.callId, JSON.stringify(record), sum.accepted, sum.rejected, sum.pending, sum.superseded ? 1 : 0)
|
|
274
|
+
if (result.changes) tally.run(sum.accepted, sum.rejected, sum.pending, sum.superseded ? 1 : 0, id)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const clear = db.prepare('DELETE FROM revert_intents WHERE call_id=?')
|
|
278
|
+
for (const entry of entries) for (const record of entry.records) clear.run(record.callId)
|
|
279
|
+
return added
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 返回某工作区所有归档批次的轻量摘要,不取记录 JSON。
|
|
284
|
+
* @author ddj 2026年09月30号
|
|
285
|
+
* @param cwd 工作区
|
|
286
|
+
* @returns 归档列表
|
|
287
|
+
*/
|
|
288
|
+
export function listArchives(cwd: string): ArchiveEntry[] {
|
|
289
|
+
const rows = dbFor(cwd).prepare(`SELECT path,batch,at,last_at,reason,n_records,accepted,rejected,pending,superseded
|
|
290
|
+
FROM batches WHERE cwd=? ORDER BY COALESCE(batch,-1) DESC, at DESC`).all(cwd) as Row[]
|
|
291
|
+
return rows.map((row) => ({
|
|
292
|
+
path: String(row.path), batch: row.batch === null ? null : Number(row.batch),
|
|
293
|
+
at: String(row.at), lastAt: String(row.last_at ?? row.at), reason: row.reason === null ? null : String(row.reason),
|
|
294
|
+
nRecords: Number(row.n_records), summary: {
|
|
295
|
+
accepted: Number(row.accepted), rejected: Number(row.rejected), pending: Number(row.pending), superseded: Number(row.superseded),
|
|
296
|
+
} as ArchiveSummary,
|
|
297
|
+
}))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 固定快照上界的 keyset 分页,新增批次不挤动后续页面。
|
|
302
|
+
* @author ddj 2026年09月30号
|
|
303
|
+
* @param cwd 工作区
|
|
304
|
+
* @param cursor 上页返回的不透明游标
|
|
305
|
+
* @param limit 每页条数,缺省 50,最多 100
|
|
306
|
+
* @returns 摘要页和下一页游标
|
|
307
|
+
*/
|
|
308
|
+
export function pageArchives(cwd: string, cursor?: string, limit = 50): ArchivePage {
|
|
309
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('归档分页大小不合法')
|
|
310
|
+
const db = dbFor(cwd)
|
|
311
|
+
const newest = Number((db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM batches WHERE cwd=?').get(cwd) as Row).id)
|
|
312
|
+
let snapshot = newest
|
|
313
|
+
let last: [number, string, number] | null = null
|
|
314
|
+
if (cursor) {
|
|
315
|
+
try {
|
|
316
|
+
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { cwd: string; snapshot: number; last: [number, string, number] }
|
|
317
|
+
if (value.cwd !== cwd || !Number.isSafeInteger(value.snapshot) || value.snapshot < 0 || value.snapshot > newest
|
|
318
|
+
|| !Array.isArray(value.last) || value.last.length !== 3 || !Number.isSafeInteger(value.last[0])
|
|
319
|
+
|| typeof value.last[1] !== 'string' || !Number.isSafeInteger(value.last[2]) || value.last[2] > value.snapshot) throw new Error('invalid')
|
|
320
|
+
snapshot = value.snapshot
|
|
321
|
+
last = value.last
|
|
322
|
+
} catch { throw new Error('归档分页游标无效') }
|
|
323
|
+
}
|
|
324
|
+
const args: Array<string | number> = [cwd, snapshot]
|
|
325
|
+
let where = 'WHERE cwd=? AND id<=?'
|
|
326
|
+
if (last) {
|
|
327
|
+
where += ' AND (COALESCE(batch,-1),at,id) < (?,?,?)'
|
|
328
|
+
args.push(...last)
|
|
329
|
+
}
|
|
330
|
+
const rows = db.prepare(`SELECT id,path,batch,at,last_at,reason,n_records,accepted,rejected,pending,superseded
|
|
331
|
+
FROM batches ${where} ORDER BY COALESCE(batch,-1) DESC, at DESC, id DESC LIMIT ?`).all(...args, limit + 1) as Row[]
|
|
332
|
+
const entries = rows.slice(0, limit).map((row) => ({
|
|
333
|
+
path: String(row.path), batch: row.batch === null ? null : Number(row.batch), at: String(row.at),
|
|
334
|
+
lastAt: String(row.last_at ?? row.at), reason: row.reason === null ? null : String(row.reason),
|
|
335
|
+
nRecords: Number(row.n_records), summary: {
|
|
336
|
+
accepted: Number(row.accepted), rejected: Number(row.rejected), pending: Number(row.pending), superseded: Number(row.superseded),
|
|
337
|
+
},
|
|
338
|
+
}))
|
|
339
|
+
const tail = rows[limit - 1]
|
|
340
|
+
const nextCursor = rows.length > limit && tail
|
|
341
|
+
? Buffer.from(JSON.stringify({ cwd, snapshot, last: [tail.batch === null ? -1 : Number(tail.batch), tail.at, tail.id] })).toString('base64url') : null
|
|
342
|
+
return { entries, nextCursor }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 仅查询需要展开的路径/批次(无 path 时维持 RPC 返回全库语义)。
|
|
347
|
+
* @author ddj 2026年09月30号
|
|
348
|
+
* @param cwd 工作区
|
|
349
|
+
* @param path 可选目标路径
|
|
350
|
+
* @param batch 可选批次
|
|
351
|
+
* @returns 原有 ArchiveBatch 格式
|
|
352
|
+
*/
|
|
353
|
+
export function readArchives(cwd: string, path?: string, batch?: number | null): ArchiveBatch[] {
|
|
354
|
+
const db = dbFor(cwd)
|
|
355
|
+
let where = 'WHERE cwd=?'
|
|
356
|
+
const args: Array<string | number> = [cwd]
|
|
357
|
+
if (path) { where += ' AND path=?'; args.push(path) }
|
|
358
|
+
if (batch !== undefined) { where += ' AND batch_key=?'; args.push(batchKey(batch)) }
|
|
359
|
+
const rows = db.prepare(`SELECT id,cwd,path,batch,at,last_at,reason FROM batches ${where} ORDER BY id`).all(...args) as Row[]
|
|
360
|
+
const records = db.prepare('SELECT payload FROM records WHERE batch_id=? ORDER BY rowid')
|
|
361
|
+
return rows.map((row) => ({
|
|
362
|
+
cwd: String(row.cwd), path: String(row.path), batch: row.batch === null ? null : Number(row.batch),
|
|
363
|
+
at: String(row.at), ...(row.last_at === null ? {} : { lastAt: String(row.last_at) }), reason: String(row.reason ?? ''),
|
|
364
|
+
records: (records.all(row.id as number) as Row[]).map((record) => JSON.parse(String(record.payload)) as ArchiveRecord),
|
|
365
|
+
}))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 查询已提交的归档记录,用于数据库成功但活跃 sidecar 写入失败后的幂等补偿。
|
|
370
|
+
* @author ddj 2026年09月30号
|
|
371
|
+
* @param cwd 工作区
|
|
372
|
+
* @param callId 调用标识
|
|
373
|
+
* @param path 目标文件路径
|
|
374
|
+
* @param batch 对应文件批次
|
|
375
|
+
* @returns 已归档快照或 null
|
|
376
|
+
*/
|
|
377
|
+
export function committedRecord(cwd: string, callId: string, path: string, batch: number | null): ArchiveRecord | null {
|
|
378
|
+
const row = dbFor(cwd).prepare(`SELECT r.payload FROM records r JOIN batches b ON b.id=r.batch_id
|
|
379
|
+
WHERE b.cwd=? AND b.path=? AND b.batch_key=? AND r.call_id=? LIMIT 1`).get(cwd, path, batchKey(batch), callId) as Row | undefined
|
|
380
|
+
return row ? JSON.parse(String(row.payload)) as ArchiveRecord : null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 查询一次 Undo 的持久化阶段;started 表示写入结果无法仅凭 sidecar 判断。
|
|
385
|
+
* @author ddj 2026年09月30号
|
|
386
|
+
* @param cwd 工作区
|
|
387
|
+
* @param callId 记录标识
|
|
388
|
+
* @param scope 决策范围
|
|
389
|
+
* @param hunkIndex 块索引(整调用为 -1)
|
|
390
|
+
* @returns 阶段或 null
|
|
391
|
+
*/
|
|
392
|
+
export function revertState(cwd: string, callId: string, scope: string, hunkIndex: number): RevertState | null {
|
|
393
|
+
const row = dbFor(cwd).prepare('SELECT state FROM revert_intents WHERE call_id=? AND scope=? AND hunk_index=?')
|
|
394
|
+
.get(callId, scope, hunkIndex) as Row | undefined
|
|
395
|
+
return row ? String(row.state) as RevertState : null
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 持久化 Undo 阶段(started 在写文件前,applied 在写文件后)。
|
|
400
|
+
* @author ddj 2026年09月30号
|
|
401
|
+
* @param cwd 工作区
|
|
402
|
+
* @param callId 记录标识
|
|
403
|
+
* @param scope 决策范围
|
|
404
|
+
* @param hunkIndex 块索引
|
|
405
|
+
* @param state 阶段
|
|
406
|
+
*/
|
|
407
|
+
export function setRevertState(cwd: string, callId: string, scope: string, hunkIndex: number, state: RevertState): void {
|
|
408
|
+
dbFor(cwd).prepare(`INSERT INTO revert_intents(call_id,scope,hunk_index,state) VALUES(?,?,?,?)
|
|
409
|
+
ON CONFLICT(call_id,scope,hunk_index) DO UPDATE SET state=excluded.state`).run(callId, scope, hunkIndex, state)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 一次事务登记同记录的所有批量 Undo 意图,登记不完整时不写任何文件。
|
|
414
|
+
* @author ddj 2026年09月30号
|
|
415
|
+
* @param cwd 工作区
|
|
416
|
+
* @param callId 记录标识
|
|
417
|
+
* @param indices 块索引
|
|
418
|
+
*/
|
|
419
|
+
export function startReverts(cwd: string, callId: string, indices: number[]): void {
|
|
420
|
+
const db = dbFor(cwd)
|
|
421
|
+
const insert = db.prepare(`INSERT INTO revert_intents(call_id,scope,hunk_index,state) VALUES(?,'hunk',?,'started')`)
|
|
422
|
+
db.exec('BEGIN IMMEDIATE')
|
|
423
|
+
try {
|
|
424
|
+
for (const index of indices) insert.run(callId, index)
|
|
425
|
+
db.exec('COMMIT')
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
428
|
+
throw error
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 撤销尚未执行成功的 Undo 意图,保留成功写入后的阶段供补偿。
|
|
434
|
+
* @author ddj 2026年09月30号
|
|
435
|
+
* @param cwd 工作区
|
|
436
|
+
* @param callId 记录标识
|
|
437
|
+
* @param scope 决策范围
|
|
438
|
+
* @param hunkIndex 块索引
|
|
439
|
+
*/
|
|
440
|
+
export function clearRevertState(cwd: string, callId: string, scope: string, hunkIndex: number): void {
|
|
441
|
+
dbFor(cwd).prepare('DELETE FROM revert_intents WHERE call_id=? AND scope=? AND hunk_index=?')
|
|
442
|
+
.run(callId, scope, hunkIndex)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 已执行但 sidecar 未更新的 Undo,从磁盘恢复决定而不重写文件。
|
|
447
|
+
* @author ddj 2026年09月30号
|
|
448
|
+
* @param cwd 工作区
|
|
449
|
+
* @param record 活跃记录
|
|
450
|
+
* @returns 是否应用了持久化决定
|
|
451
|
+
*/
|
|
452
|
+
export function restoreReverts(cwd: string, record: DiffRecord): boolean {
|
|
453
|
+
const rows = dbFor(cwd).prepare('SELECT scope,hunk_index,state FROM revert_intents WHERE call_id=?')
|
|
454
|
+
.all(record.callId) as Row[]
|
|
455
|
+
let changed = false
|
|
456
|
+
for (const row of rows) {
|
|
457
|
+
if (row.state !== 'applied') continue
|
|
458
|
+
if (row.scope === 'call') {
|
|
459
|
+
record.decisions.call = 'rejected'
|
|
460
|
+
record.decisions.perHunk = record.decisions.perHunk.map(() => 'rejected')
|
|
461
|
+
} else if (row.scope === 'hunk' && Number(row.hunk_index) >= 0 && Number(row.hunk_index) < record.hunks.length) {
|
|
462
|
+
record.decisions.perHunk[Number(row.hunk_index)] = 'rejected'
|
|
463
|
+
} else continue
|
|
464
|
+
changed = true
|
|
465
|
+
}
|
|
466
|
+
return changed
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 关闭所有工作区连接,供 Host 卸载以及测试释放 SQLite 句柄。
|
|
471
|
+
* @author ddj 2026年09月30号
|
|
472
|
+
*/
|
|
473
|
+
export function closeArchiveDbs(): void {
|
|
474
|
+
for (const db of connections.values()) db.close()
|
|
475
|
+
connections.clear()
|
|
476
|
+
}
|
package/src/capture.ts
CHANGED
|
@@ -240,12 +240,19 @@ async function buildRemoteRecord(cwd: string, exec: CaptureExec, args: Record<st
|
|
|
240
240
|
async function persistRecord(ctx: Ctx, registry: Registry, session: Session, cwd: string, record: DiffRecord): Promise<void> {
|
|
241
241
|
let bucket = registry.get(cwd)
|
|
242
242
|
if (!bucket) { bucket = await loadBucket(ctx, cwd); registry.set(cwd, bucket) }
|
|
243
|
+
const prior = new Map(bucket)
|
|
243
244
|
record.batch = fileMaxBatch(bucket, record.path) + 1
|
|
244
245
|
bucket.set(record.callId, record)
|
|
245
246
|
// 同一文件的多次 edit/write 都保留为独立记录,便于逐条审查。
|
|
246
247
|
// 旧实现按文件 batch 自动归档 prior,会导致界面看起来每个文件只剩一条差异。
|
|
247
248
|
prune(bucket)
|
|
248
|
-
|
|
249
|
+
const removed = [...prior.keys()].filter((callId) => !bucket.has(callId))
|
|
250
|
+
try { await saveBucket(ctx, cwd, bucket, session, { upserts: [record], removed }) }
|
|
251
|
+
catch (error) {
|
|
252
|
+
bucket.clear()
|
|
253
|
+
for (const [callId, rec] of prior) bucket.set(callId, rec)
|
|
254
|
+
throw error
|
|
255
|
+
}
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
/**
|
|
@@ -13,9 +13,27 @@ export type DiffDockMode = 'chat' | 'editor' | 'editor-empty'
|
|
|
13
13
|
*/
|
|
14
14
|
export interface DiffDockSnapshot {
|
|
15
15
|
mode: DiffDockMode
|
|
16
|
+
layout?: 'side' | 'tab'
|
|
16
17
|
[key: string]: unknown
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 指定布局仅在仍有待处理差异时显示差异栏,冲突差异和其他文件差异也保留入口。
|
|
22
|
+
* @public
|
|
23
|
+
* @author ddj 2026年10月08号
|
|
24
|
+
* @param snapshot 编辑器发布的差异快照
|
|
25
|
+
* @param layout 目标布局
|
|
26
|
+
* @returns 是否显示差异栏
|
|
27
|
+
*/
|
|
28
|
+
export function dockInLayout(snapshot: DiffDockSnapshot | null, layout: 'side' | 'tab'): boolean {
|
|
29
|
+
if (!snapshot || (snapshot.layout ?? 'tab') !== layout) return false
|
|
30
|
+
return Number(snapshot.fileTotal) > 0
|
|
31
|
+
|| Number(snapshot.allPendingCount) > 0
|
|
32
|
+
|| Number(snapshot.diffTotal) > 0
|
|
33
|
+
|| (Array.isArray(snapshot.pendingRegions) && snapshot.pendingRegions.length > 0)
|
|
34
|
+
|| (Array.isArray(snapshot.staleRegions) && snapshot.staleRegions.length > 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
type DiffDockListener = () => void
|
|
20
38
|
type DiffDockSource = object
|
|
21
39
|
|