dsh-recall-plugin 1.0.1 → 1.0.3
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 +44 -2
- package/lib/index.js +55 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
- **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
|
|
28
28
|
- **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
|
|
29
|
-
- **项目目录保持干净**:快照始终存在 `$DSH_HOME` 下,不会往项目里塞任何东西;与会话的沙箱权限无关(workspace-write / read-only 会话照常快照与回退),仅当 home 本身不可写(如指到只读盘)才降级到项目内 `.dsh-recall-snapshots
|
|
29
|
+
- **项目目录保持干净**:快照始终存在 `$DSH_HOME` 下,不会往项目里塞任何东西;与会话的沙箱权限无关(workspace-write / read-only 会话照常快照与回退),仅当 home 本身不可写(如指到只读盘)才降级到项目内 `.dsh-recall-snapshots`(降级时页面会提示),home 恢复后自动迁走、清理干净。
|
|
30
30
|
- **可以反复后悔**:快照全量保留、永不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。
|
|
31
31
|
- **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
|
|
32
32
|
- **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
## 安装
|
|
42
42
|
|
|
43
|
-
前置:Windows + git CLI(未装 git
|
|
43
|
+
前置:Windows + git CLI(未装 git 时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);PowerShell 5.1 / 7 均可;DSH 0.1.0-rc.x(依赖版本见 `peerDependencies`)。
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
- DSH 官方插件命令:安装并自动挂载进 web profile
|
package/lib/client.js
CHANGED
|
@@ -59,7 +59,10 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
'.dsh-recall-btn{border:none;border-radius:8px;padding:5px 14px;font-size:13px;line-height:20px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
60
60
|
'.dsh-recall-btn:hover{color:var(--dsw-alias-label-primary)}',
|
|
61
61
|
'.dsh-recall-btn-danger{background:var(--dsw-alias-state-error-primary);color:#fff}',
|
|
62
|
-
'.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}'
|
|
62
|
+
'.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}',
|
|
63
|
+
'.dsh-recall-toast{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:10000;max-width:min(560px,86vw);box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:10px 16px;font-size:13px;line-height:20px;box-shadow:0 8px 28px rgba(0,0,0,.22);display:flex;align-items:baseline;gap:8px;opacity:0;transition:opacity .25s ease;pointer-events:auto}',
|
|
64
|
+
'.dsh-recall-toast.dsh-recall-toast-in{opacity:1}',
|
|
65
|
+
'.dsh-recall-toast-tag{flex:none;font-weight:600;color:var(--dsw-alias-state-error-primary)}'
|
|
63
66
|
].join('')
|
|
64
67
|
// 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
|
|
65
68
|
const stylesSvc = ctx.get('styles')
|
|
@@ -75,6 +78,37 @@ window.__ModuleLoader__.load({
|
|
|
75
78
|
let initedSessionId = null
|
|
76
79
|
let initDone = Promise.resolve()
|
|
77
80
|
|
|
81
|
+
// 降级提示:每个种类每次页面加载只弹一次(Set 去重),避免切会话时
|
|
82
|
+
// 反复打扰。纯 DOM 直插(与剪贴板同样的零依赖思路),7 秒后自动淡出。
|
|
83
|
+
const noticeShown = new Set()
|
|
84
|
+
function showNotice(kind, text) {
|
|
85
|
+
if (noticeShown.has(kind) || typeof document === 'undefined') return
|
|
86
|
+
noticeShown.add(kind)
|
|
87
|
+
try {
|
|
88
|
+
const el = document.createElement('div')
|
|
89
|
+
el.className = 'dsh-recall-toast'
|
|
90
|
+
const tag = document.createElement('span')
|
|
91
|
+
tag.className = 'dsh-recall-toast-tag'
|
|
92
|
+
tag.textContent = '撤回插件'
|
|
93
|
+
const body = document.createElement('span')
|
|
94
|
+
body.textContent = text
|
|
95
|
+
el.appendChild(tag)
|
|
96
|
+
el.appendChild(body)
|
|
97
|
+
el.addEventListener('click', () => dismiss(), { once: true })
|
|
98
|
+
document.body.appendChild(el)
|
|
99
|
+
requestAnimationFrame(() => el.classList.add('dsh-recall-toast-in'))
|
|
100
|
+
const timer = setTimeout(dismiss, 7000)
|
|
101
|
+
let dismissed = false
|
|
102
|
+
function dismiss() {
|
|
103
|
+
if (dismissed) return
|
|
104
|
+
dismissed = true
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
el.classList.remove('dsh-recall-toast-in')
|
|
107
|
+
setTimeout(() => el.remove(), 300)
|
|
108
|
+
}
|
|
109
|
+
} catch (e) { /* 提示失败不影响主流程 */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
// 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
|
|
79
113
|
// 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
|
|
80
114
|
// snapshot-info 必须等它完成后再查,否则冷启动时索引尚未载入会误判
|
|
@@ -82,7 +116,15 @@ window.__ModuleLoader__.load({
|
|
|
82
116
|
function ensureInit(sessionId) {
|
|
83
117
|
if (!sessionId || initedSessionId === sessionId) return initDone
|
|
84
118
|
initedSessionId = sessionId
|
|
85
|
-
initDone = api('init', { sessionId }).
|
|
119
|
+
initDone = api('init', { sessionId }).then((res) => {
|
|
120
|
+
const notice = res && res.notice
|
|
121
|
+
if (notice && notice.gitMissing) {
|
|
122
|
+
showNotice('git', '未检测到 git CLI,撤回功能不可用(快照引擎依赖 git)。安装 git 并重启 DSH 后即可使用。')
|
|
123
|
+
}
|
|
124
|
+
if (notice && notice.homeFallback) {
|
|
125
|
+
showNotice('home', 'home 目录不可写,快照已降级存储到项目内 .dsh-recall-snapshots 目录。')
|
|
126
|
+
}
|
|
127
|
+
}).catch(() => {})
|
|
86
128
|
return initDone
|
|
87
129
|
}
|
|
88
130
|
|
package/lib/index.js
CHANGED
|
@@ -83,16 +83,35 @@ export function apply(ctx) {
|
|
|
83
83
|
const sp = ctx.get('sandboxPolicy')
|
|
84
84
|
if (sp && sp.workspaceRoot) root = sp.workspaceRoot
|
|
85
85
|
}
|
|
86
|
+
// 规范化尾部反斜杠(保留 "D:\" 三字符盘根形态,去掉会变成无效的 "D:"):
|
|
87
|
+
// cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store 目录),
|
|
88
|
+
// 也会让大文件排除扫描的 Substring($root.Length + 1) 相对路径错一位。
|
|
89
|
+
if (root && root.length > 3) root = root.replace(/\\+$/, '')
|
|
86
90
|
if (root) state.roots.set(key, root)
|
|
87
91
|
return root
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
// 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,
|
|
91
95
|
// 求值一次并缓存,脚本里用绝对路径调用,避免每条命令依赖 PATH。
|
|
96
|
+
// 候选覆盖 PATH / 64 位 / 32 位 / 用户级(LocalAppData)四类安装位置,
|
|
97
|
+
// 并用 -PathType Leaf 挡掉 PATH 里恰有名为 git 的目录这种极端情形。
|
|
92
98
|
async function resolveGit() {
|
|
93
99
|
if (state.gitExe !== null) return state.gitExe
|
|
94
100
|
try {
|
|
95
|
-
|
|
101
|
+
// 逐项判空再 Join-Path:个别 env 在特殊环境(32 位系统无
|
|
102
|
+
// ProgramFiles(x86))取到 null,EAP=Stop 下 Join-Path 抛错会
|
|
103
|
+
// 让整个探测失败、误报 gitMissing。
|
|
104
|
+
const script = [
|
|
105
|
+
'$candidates = @()',
|
|
106
|
+
'$g = (Get-Command git -ErrorAction SilentlyContinue).Source',
|
|
107
|
+
'if ($g) { $candidates += $g }',
|
|
108
|
+
"if (${env:ProgramFiles}) { $candidates += (Join-Path ${env:ProgramFiles} 'Git\\cmd\\git.exe') }",
|
|
109
|
+
"if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'Git\\cmd\\git.exe') }",
|
|
110
|
+
"if (${env:LocalAppData}) { $candidates += (Join-Path ${env:LocalAppData} 'Programs\\Git\\cmd\\git.exe') }",
|
|
111
|
+
"$g = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1",
|
|
112
|
+
'if ($g) { Write-Output $g }'
|
|
113
|
+
].join('\n')
|
|
114
|
+
const path = stripBom(await runShell(script, { stdoutMaxBytes: 4096 })).trim()
|
|
96
115
|
state.gitExe = path || ''
|
|
97
116
|
} catch (error) {
|
|
98
117
|
state.gitExe = ''
|
|
@@ -105,9 +124,13 @@ export function apply(ctx) {
|
|
|
105
124
|
// 后两者是 .NET 5+(仅 PS 7)API,别人机器的 shell 若是 Windows PowerShell
|
|
106
125
|
// 5.1 会抛错,导致 home 存储永远降级到项目内;前者两个版本都可用。
|
|
107
126
|
async function homeDirFor(root, sessionId) {
|
|
127
|
+
// DSH 的 pwsh 子进程按白名单重建 env,会话级导出的 DSH_HOME 传不进去;
|
|
128
|
+
// 主进程(node)仍能看到它,作为字面量回退注入,保证「DSH_HOME 指到哪、
|
|
129
|
+
// 快照就存哪」在任意导出层级下都成立。
|
|
130
|
+
const envHome = (process.env && process.env.DSH_HOME) || ''
|
|
108
131
|
const dirScript = [
|
|
109
132
|
'$r = ' + psq(root),
|
|
110
|
-
|
|
133
|
+
"$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + " } else { Join-Path $env:USERPROFILE \".dsh\" }",
|
|
111
134
|
'$sha = [Security.Cryptography.SHA256]::Create()',
|
|
112
135
|
"$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
|
|
113
136
|
"Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
|
|
@@ -260,7 +283,10 @@ export function apply(ctx) {
|
|
|
260
283
|
dropGitlinksScript(),
|
|
261
284
|
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
262
285
|
dropGitlinksScript(),
|
|
263
|
-
|
|
286
|
+
// 扫描加 SilentlyContinue:EAP=Stop 下个别不可访问子目录(杀软锁定、
|
|
287
|
+
// 异常 ACL、损坏 junction)的非致命错误会被升级为终止,整条快照作废;
|
|
288
|
+
// 本扫描只用于排除超大文件,漏看个别文件是 fail-open,可接受。
|
|
289
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
264
290
|
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
265
291
|
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
266
292
|
'}',
|
|
@@ -290,7 +316,7 @@ export function apply(ctx) {
|
|
|
290
316
|
dropGitlinksScript(),
|
|
291
317
|
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
292
318
|
dropGitlinksScript(),
|
|
293
|
-
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
319
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
294
320
|
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
295
321
|
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
296
322
|
'}',
|
|
@@ -342,7 +368,7 @@ export function apply(ctx) {
|
|
|
342
368
|
dropGitlinksScript(),
|
|
343
369
|
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
344
370
|
dropGitlinksScript(),
|
|
345
|
-
'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
371
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
346
372
|
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
347
373
|
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
348
374
|
'}',
|
|
@@ -390,7 +416,11 @@ export function apply(ctx) {
|
|
|
390
416
|
].join('\n')
|
|
391
417
|
}
|
|
392
418
|
|
|
393
|
-
// 索引写入合并为单次 shell 调用:pwsh
|
|
419
|
+
// 索引写入合并为单次 shell 调用:pwsh 进程启动是主要耗时,能省一次是一次。
|
|
420
|
+
// base64 以内联字面量传递时受 Windows 命令行 32767 字符硬上限约束(DSH 的
|
|
421
|
+
// pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn),快照攒到几百条
|
|
422
|
+
// 就会超限 spawn 失败——按 20000 字符分块,首块 Set-Content、续块 Add-Content,
|
|
423
|
+
// 常规体量仍是单次调用,超限后自动多写几块。
|
|
394
424
|
async function saveIndex(root, sessionId) {
|
|
395
425
|
const store = state.stores.get(root)
|
|
396
426
|
if (!store) return
|
|
@@ -400,7 +430,16 @@ export function apply(ctx) {
|
|
|
400
430
|
const json = JSON.stringify(entries)
|
|
401
431
|
try {
|
|
402
432
|
const b64 = Buffer.from(json, 'utf8').toString('base64')
|
|
403
|
-
|
|
433
|
+
const file = psq(store.dir + '\\index.json')
|
|
434
|
+
let first = true
|
|
435
|
+
for (let i = 0; i < b64.length; i += 20000) {
|
|
436
|
+
const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64.slice(i, i + 20000) + "')) | "
|
|
437
|
+
const cmd = first
|
|
438
|
+
? 'New-Item -ItemType Directory -Force -Path ' + psq(store.dir) + ' | Out-Null; ' + piece + 'Set-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
|
|
439
|
+
: piece + 'Add-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
|
|
440
|
+
await runShell(cmd, { stdoutMaxBytes: 4096 })
|
|
441
|
+
first = false
|
|
442
|
+
}
|
|
404
443
|
} catch (error) {
|
|
405
444
|
console.error('recall saveIndex failed:', String(error))
|
|
406
445
|
}
|
|
@@ -570,6 +609,7 @@ export function apply(ctx) {
|
|
|
570
609
|
if (name === 'init') {
|
|
571
610
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
572
611
|
const root = await resolveRoot(sessionId)
|
|
612
|
+
let notice = null
|
|
573
613
|
if (root) {
|
|
574
614
|
let store = await resolveStore(root, sessionId)
|
|
575
615
|
store = await tryUpgradeToHome(root, sessionId)
|
|
@@ -577,8 +617,15 @@ export function apply(ctx) {
|
|
|
577
617
|
await loadIndex(root, sessionId)
|
|
578
618
|
await rebuildOrphans(root, sessionId)
|
|
579
619
|
cleanupLegacy(root, sessionId)
|
|
620
|
+
// 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
|
|
621
|
+
// gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
|
|
622
|
+
// 不可写,快照降级存进项目内 .dsh-recall-snapshots。
|
|
623
|
+
notice = {
|
|
624
|
+
gitMissing: state.gitExe === '',
|
|
625
|
+
homeFallback: store ? !store.home : false
|
|
626
|
+
}
|
|
580
627
|
}
|
|
581
|
-
sendJson(res, 200, { ok: Boolean(root), root: root || null })
|
|
628
|
+
sendJson(res, 200, { ok: Boolean(root), root: root || null, notice })
|
|
582
629
|
return
|
|
583
630
|
}
|
|
584
631
|
if (name === 'snapshot-info') {
|