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 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
- let path = stripBom(await runShell("$g = (Get-Command git -ErrorAction SilentlyContinue).Source; if (-not $g -and (Test-Path -LiteralPath 'C:\\Program Files\\Git\\cmd\\git.exe')) { $g = 'C:\\Program Files\\Git\\cmd\\git.exe' }; if ($g) { Write-Output $g }", { stdoutMaxBytes: 4096 })).trim()
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
- '$h = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE ".dsh" }',
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
- 'Get-ChildItem -LiteralPath $root -Recurse -File -Force | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
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
- await runShell("New-Item -ItemType Directory -Force -Path " + psq(store.dir) + " | Out-Null; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "')) | Set-Content -LiteralPath " + psq(store.dir + '\\index.json') + ' -Encoding utf8 -NoNewline', { stdoutMaxBytes: 4096 })
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {