dsh-recall-plugin 1.0.2 → 1.0.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 +2 -2
- package/lib/client.js +11 -1
- package/lib/index.js +70 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
---
|
|
12
12
|
**在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
|
|
13
13
|
|
|
14
|
-
|
|
15
14
|
## 界面预览
|
|
16
15
|
- 撤回按钮位置
|
|
17
16
|
|
|
@@ -35,8 +34,9 @@
|
|
|
35
34
|
|
|
36
35
|
- 快照在**消息发送时**创建,插件启用前的历史消息没有快照,不显示撤回按钮。
|
|
37
36
|
- 会话第一条用户消息无法回退对话(仅文件回退),因为 fork 需要更早的 turn 边界。
|
|
38
|
-
- 仅支持 Windows(脚本用 PowerShell + git CLI);Linux/macOS
|
|
37
|
+
- 仅支持 Windows(脚本用 PowerShell + git CLI);Linux/macOS 下页面会弹一次性「仅支持 Windows」提示,快照不可用、不显示撤回按钮,也不会产生任何文件副作用。
|
|
39
38
|
- 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)不进快照,其内容不参与回退。
|
|
39
|
+
- MacOS和Linux环境未进行测试
|
|
40
40
|
|
|
41
41
|
## 安装
|
|
42
42
|
|
package/lib/client.js
CHANGED
|
@@ -118,19 +118,29 @@ window.__ModuleLoader__.load({
|
|
|
118
118
|
initedSessionId = sessionId
|
|
119
119
|
initDone = api('init', { sessionId }).then((res) => {
|
|
120
120
|
const notice = res && res.notice
|
|
121
|
+
if (notice && notice.unsupported) {
|
|
122
|
+
showNotice('unsupported', '撤回插件仅支持 Windows,当前平台的快照不可用。')
|
|
123
|
+
}
|
|
121
124
|
if (notice && notice.gitMissing) {
|
|
122
125
|
showNotice('git', '未检测到 git CLI,撤回功能不可用(快照引擎依赖 git)。安装 git 并重启 DSH 后即可使用。')
|
|
123
126
|
}
|
|
124
127
|
if (notice && notice.homeFallback) {
|
|
125
128
|
showNotice('home', 'home 目录不可写,快照已降级存储到项目内 .dsh-recall-snapshots 目录。')
|
|
126
129
|
}
|
|
127
|
-
}).catch(() => {
|
|
130
|
+
}).catch(() => {
|
|
131
|
+
// init 失败(如页面先于 Host API 就绪加载)时清掉标记:否则本会话
|
|
132
|
+
// 内被判定“已初始化”,撤回按钮永不出现;清掉后下一条消息挂载会重试
|
|
133
|
+
if (initedSessionId === sessionId) initedSessionId = null
|
|
134
|
+
})
|
|
128
135
|
return initDone
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
// 消息时间:当天只显示时分,跨天显示月/日 时分
|
|
132
139
|
function clockText(ms) {
|
|
133
140
|
try {
|
|
141
|
+
// time 字段缺失或非法时返回空串:Invalid Date 不会 throw,
|
|
142
|
+
// 不拦会渲染出 "NaN/NaN NaN:NaN" 这样的坏时间戳
|
|
143
|
+
if (!ms || isNaN(new Date(ms).getTime())) return ''
|
|
134
144
|
const d = new Date(ms)
|
|
135
145
|
const now = new Date()
|
|
136
146
|
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,13 @@ export const inject = ['shell', 'sessions', 'webServer']
|
|
|
19
19
|
const MAX_FILE_BYTES = 104857600
|
|
20
20
|
const HOME_RETRY_MS = 300000
|
|
21
21
|
|
|
22
|
+
// 统一 UTF-8 输出前导:中文等非 ASCII 机器的默认代码页(如 GBK)下,
|
|
23
|
+
// PowerShell 重定向 stdout 按 [Console]::OutputEncoding 编码,而 DSH 按
|
|
24
|
+
// UTF-8 解码——不强制时 homeDirFor/resolveGit 输出里含中文的用户名/路径
|
|
25
|
+
// 会变乱码,store 会指向错误目录。diff/rollback 脚本里早有同款设置,
|
|
26
|
+
// 这里提到 runShell 全局注入一次,其余脚本不改自动受益(PS 5.1 / 7 均支持)。
|
|
27
|
+
const UTF8_PRELUDE = '$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}'
|
|
28
|
+
|
|
22
29
|
export function apply(ctx) {
|
|
23
30
|
const shell = ctx.shell
|
|
24
31
|
const sessions = ctx.sessions
|
|
@@ -36,6 +43,12 @@ export function apply(ctx) {
|
|
|
36
43
|
gitExe: null
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
// 非 Windows 平台干净降级:脚本体系(反斜杠路径、Windows git 安装候选、
|
|
47
|
+
// Expand-Archive)绑定 Windows,硬跑不报错但会在项目里创建名字带反斜杠的
|
|
48
|
+
// 垃圾目录。这里整体短路:init 返回 unsupported,Client 弹一次性提示;
|
|
49
|
+
// 其余端点因无快照自然返回「没有可用快照」,全程零文件副作用。
|
|
50
|
+
const supported = process.platform === 'win32'
|
|
51
|
+
|
|
39
52
|
function psq(value) {
|
|
40
53
|
return "'" + String(value).replace(/'/g, "''") + "'"
|
|
41
54
|
}
|
|
@@ -52,7 +65,7 @@ export function apply(ctx) {
|
|
|
52
65
|
async function runShell(command, opts) {
|
|
53
66
|
const sp = ctx.get('sandboxPolicy')
|
|
54
67
|
const spec = shell.resolve({
|
|
55
|
-
command,
|
|
68
|
+
command: UTF8_PRELUDE + '\n' + command,
|
|
56
69
|
timeoutMs: (opts && opts.timeoutMs) || 300000,
|
|
57
70
|
stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
|
|
58
71
|
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
|
|
@@ -83,16 +96,35 @@ export function apply(ctx) {
|
|
|
83
96
|
const sp = ctx.get('sandboxPolicy')
|
|
84
97
|
if (sp && sp.workspaceRoot) root = sp.workspaceRoot
|
|
85
98
|
}
|
|
99
|
+
// 规范化尾部反斜杠(保留 "D:\" 三字符盘根形态,去掉会变成无效的 "D:"):
|
|
100
|
+
// cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store 目录),
|
|
101
|
+
// 也会让大文件排除扫描的 Substring($root.Length + 1) 相对路径错一位。
|
|
102
|
+
if (root && root.length > 3) root = root.replace(/\\+$/, '')
|
|
86
103
|
if (root) state.roots.set(key, root)
|
|
87
104
|
return root
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
// 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,
|
|
91
108
|
// 求值一次并缓存,脚本里用绝对路径调用,避免每条命令依赖 PATH。
|
|
109
|
+
// 候选覆盖 PATH / 64 位 / 32 位 / 用户级(LocalAppData)四类安装位置,
|
|
110
|
+
// 并用 -PathType Leaf 挡掉 PATH 里恰有名为 git 的目录这种极端情形。
|
|
92
111
|
async function resolveGit() {
|
|
93
112
|
if (state.gitExe !== null) return state.gitExe
|
|
94
113
|
try {
|
|
95
|
-
|
|
114
|
+
// 逐项判空再 Join-Path:个别 env 在特殊环境(32 位系统无
|
|
115
|
+
// ProgramFiles(x86))取到 null,EAP=Stop 下 Join-Path 抛错会
|
|
116
|
+
// 让整个探测失败、误报 gitMissing。
|
|
117
|
+
const script = [
|
|
118
|
+
'$candidates = @()',
|
|
119
|
+
'$g = (Get-Command git -ErrorAction SilentlyContinue).Source',
|
|
120
|
+
'if ($g) { $candidates += $g }',
|
|
121
|
+
"if (${env:ProgramFiles}) { $candidates += (Join-Path ${env:ProgramFiles} 'Git\\cmd\\git.exe') }",
|
|
122
|
+
"if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'Git\\cmd\\git.exe') }",
|
|
123
|
+
"if (${env:LocalAppData}) { $candidates += (Join-Path ${env:LocalAppData} 'Programs\\Git\\cmd\\git.exe') }",
|
|
124
|
+
"$g = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1",
|
|
125
|
+
'if ($g) { Write-Output $g }'
|
|
126
|
+
].join('\n')
|
|
127
|
+
const path = stripBom(await runShell(script, { stdoutMaxBytes: 4096 })).trim()
|
|
96
128
|
state.gitExe = path || ''
|
|
97
129
|
} catch (error) {
|
|
98
130
|
state.gitExe = ''
|
|
@@ -105,9 +137,13 @@ export function apply(ctx) {
|
|
|
105
137
|
// 后两者是 .NET 5+(仅 PS 7)API,别人机器的 shell 若是 Windows PowerShell
|
|
106
138
|
// 5.1 会抛错,导致 home 存储永远降级到项目内;前者两个版本都可用。
|
|
107
139
|
async function homeDirFor(root, sessionId) {
|
|
140
|
+
// DSH 的 pwsh 子进程按白名单重建 env,会话级导出的 DSH_HOME 传不进去;
|
|
141
|
+
// 主进程(node)仍能看到它,作为字面量回退注入,保证「DSH_HOME 指到哪、
|
|
142
|
+
// 快照就存哪」在任意导出层级下都成立。
|
|
143
|
+
const envHome = (process.env && process.env.DSH_HOME) || ''
|
|
108
144
|
const dirScript = [
|
|
109
145
|
'$r = ' + psq(root),
|
|
110
|
-
|
|
146
|
+
"$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + " } else { Join-Path $env:USERPROFILE \".dsh\" }",
|
|
111
147
|
'$sha = [Security.Cryptography.SHA256]::Create()',
|
|
112
148
|
"$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
|
|
113
149
|
"Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
|
|
@@ -117,6 +153,9 @@ export function apply(ctx) {
|
|
|
117
153
|
// PS 的 Join-Path 可能带出连续反斜杠(旧版脚本遗留 "…\\<hex>" 形态);
|
|
118
154
|
// 折叠成单反斜杠。Windows 把中间的重复分隔符视为同一个目录,
|
|
119
155
|
// 所以与既有快照数据(index.json、影子 git 仓库)路径完全兼容。
|
|
156
|
+
// 开头的双反斜杠是 UNC 前缀(DSH_HOME/用户主目录指到网络盘),
|
|
157
|
+
// 折叠掉会把 \\server\share 变成无效的 \server\share,必须原样保留。
|
|
158
|
+
if (/^\\\\/.test(text)) return '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\')
|
|
120
159
|
return text.replace(/\\{2,}/g, '\\')
|
|
121
160
|
}
|
|
122
161
|
|
|
@@ -260,7 +299,10 @@ export function apply(ctx) {
|
|
|
260
299
|
dropGitlinksScript(),
|
|
261
300
|
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
262
301
|
dropGitlinksScript(),
|
|
263
|
-
|
|
302
|
+
// 扫描加 SilentlyContinue:EAP=Stop 下个别不可访问子目录(杀软锁定、
|
|
303
|
+
// 异常 ACL、损坏 junction)的非致命错误会被升级为终止,整条快照作废;
|
|
304
|
+
// 本扫描只用于排除超大文件,漏看个别文件是 fail-open,可接受。
|
|
305
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
264
306
|
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
265
307
|
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
266
308
|
'}',
|
|
@@ -290,7 +332,7 @@ export function apply(ctx) {
|
|
|
290
332
|
dropGitlinksScript(),
|
|
291
333
|
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
292
334
|
dropGitlinksScript(),
|
|
293
|
-
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
335
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
294
336
|
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
295
337
|
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
296
338
|
'}',
|
|
@@ -342,7 +384,7 @@ export function apply(ctx) {
|
|
|
342
384
|
dropGitlinksScript(),
|
|
343
385
|
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
344
386
|
dropGitlinksScript(),
|
|
345
|
-
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
387
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
346
388
|
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
347
389
|
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
348
390
|
'}',
|
|
@@ -390,7 +432,11 @@ export function apply(ctx) {
|
|
|
390
432
|
].join('\n')
|
|
391
433
|
}
|
|
392
434
|
|
|
393
|
-
// 索引写入合并为单次 shell 调用:pwsh
|
|
435
|
+
// 索引写入合并为单次 shell 调用:pwsh 进程启动是主要耗时,能省一次是一次。
|
|
436
|
+
// base64 以内联字面量传递时受 Windows 命令行 32767 字符硬上限约束(DSH 的
|
|
437
|
+
// pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn),快照攒到几百条
|
|
438
|
+
// 就会超限 spawn 失败——按 20000 字符分块,首块 Set-Content、续块 Add-Content,
|
|
439
|
+
// 常规体量仍是单次调用,超限后自动多写几块。
|
|
394
440
|
async function saveIndex(root, sessionId) {
|
|
395
441
|
const store = state.stores.get(root)
|
|
396
442
|
if (!store) return
|
|
@@ -400,7 +446,16 @@ export function apply(ctx) {
|
|
|
400
446
|
const json = JSON.stringify(entries)
|
|
401
447
|
try {
|
|
402
448
|
const b64 = Buffer.from(json, 'utf8').toString('base64')
|
|
403
|
-
|
|
449
|
+
const file = psq(store.dir + '\\index.json')
|
|
450
|
+
let first = true
|
|
451
|
+
for (let i = 0; i < b64.length; i += 20000) {
|
|
452
|
+
const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64.slice(i, i + 20000) + "')) | "
|
|
453
|
+
const cmd = first
|
|
454
|
+
? 'New-Item -ItemType Directory -Force -Path ' + psq(store.dir) + ' | Out-Null; ' + piece + 'Set-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
|
|
455
|
+
: piece + 'Add-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
|
|
456
|
+
await runShell(cmd, { stdoutMaxBytes: 4096 })
|
|
457
|
+
first = false
|
|
458
|
+
}
|
|
404
459
|
} catch (error) {
|
|
405
460
|
console.error('recall saveIndex failed:', String(error))
|
|
406
461
|
}
|
|
@@ -568,6 +623,10 @@ export function apply(ctx) {
|
|
|
568
623
|
try {
|
|
569
624
|
const args = await readJsonBody(req)
|
|
570
625
|
if (name === 'init') {
|
|
626
|
+
if (!supported) {
|
|
627
|
+
sendJson(res, 200, { ok: false, root: null, notice: { unsupported: true } })
|
|
628
|
+
return
|
|
629
|
+
}
|
|
571
630
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
572
631
|
const root = await resolveRoot(sessionId)
|
|
573
632
|
let notice = null
|
|
@@ -631,6 +690,9 @@ export function apply(ctx) {
|
|
|
631
690
|
}
|
|
632
691
|
}))
|
|
633
692
|
|
|
693
|
+
// 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
|
|
694
|
+
if (!supported) return
|
|
695
|
+
|
|
634
696
|
// 每条用户消息触发快照(子代理会话跳过)
|
|
635
697
|
ctx.on('session/event', (session, event) => {
|
|
636
698
|
if (!event || event.type !== 'user/message') return
|