zen-gitsync 2.16.41 → 2.16.43

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.
Files changed (89) hide show
  1. package/package.json +1 -1
  2. package/src/ui/public/assets/AgentView-8-xsOmE6.css +1 -0
  3. package/src/ui/public/assets/AgentView-CVfH_WTL.js +4 -0
  4. package/src/ui/public/assets/AppVersionBadge-BKQZrA46.js +2 -0
  5. package/src/ui/public/assets/AppVersionBadge-CS7Trvp-.css +1 -0
  6. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +1 -0
  7. package/src/ui/public/assets/BranchSelector-BrmzYPb2.js +1 -0
  8. package/src/ui/public/assets/CommitForm-CSAwdqxy.js +6 -0
  9. package/src/ui/public/assets/CommitForm-DdK8dymc.css +1 -0
  10. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +1 -0
  11. package/src/ui/public/assets/CommonDialog-Clc3mo5h.js +1 -0
  12. package/src/ui/public/assets/EditorView-B05M4l8X.js +1 -0
  13. package/src/ui/public/assets/EditorView-DZMLvQds.css +1 -0
  14. package/src/ui/public/assets/FlowExecutionViewer-Du0JPzV8.js +1 -0
  15. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +1 -0
  16. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +1 -0
  17. package/src/ui/public/assets/FlowOrchestrationWorkspace-Kg7HaggD.js +21 -0
  18. package/src/ui/public/assets/LogList-BlJplIi1.css +1 -0
  19. package/src/ui/public/assets/LogList-DSl8gjqq.js +7 -0
  20. package/src/ui/public/assets/MindmapView-BQTqx842.css +1 -0
  21. package/src/ui/public/assets/MindmapView-KeS9uPEF.js +2 -0
  22. package/src/ui/public/assets/MonitorView-BYqPPmpL.js +1 -0
  23. package/src/ui/public/assets/MonitorView-TaS4kL0Y.css +1 -0
  24. package/src/ui/public/assets/ProjectStartupButton-COEXIVyh.js +1 -0
  25. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +1 -0
  26. package/src/ui/public/assets/RemoteRepoCard-BRGNlbeO.css +1 -0
  27. package/src/ui/public/assets/RemoteRepoCard-Cqyksiy4.js +5 -0
  28. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +1 -0
  29. package/src/ui/public/assets/SourceMapView-DhqMQTQD.js +3 -0
  30. package/src/ui/public/assets/{SvgIcon-BQ6bR7l0.css → SvgIcon-CDNsk4-Q.css} +1 -1
  31. package/src/ui/public/assets/SvgIcon-DLr4V32f.js +1 -0
  32. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +1 -0
  33. package/src/ui/public/assets/UserInputNode-Do__vhj9.js +1 -0
  34. package/src/ui/public/assets/{WorkbenchView-CcYljxjT.css → WorkbenchView-Bp2YaSGS.css} +1 -1
  35. package/src/ui/public/assets/WorkbenchView-Dk5OlqZz.js +28 -0
  36. package/src/ui/public/assets/{_plugin-vue_export-helper-CkojBFWh.js → _plugin-vue_export-helper-CMAHJXhc.js} +4 -4
  37. package/src/ui/public/assets/{configStore-BisOZpe4.js → configStore-D666qKi0.js} +1 -1
  38. package/src/ui/public/assets/element-plus-DXuL5yWp.js +39 -0
  39. package/src/ui/public/assets/{flow-mindmap-D4bc307Q.js → flow-mindmap-Bi227PSm.js} +12 -12
  40. package/src/ui/public/assets/index-BT90zGWp.js +89 -0
  41. package/src/ui/public/assets/index-RKGE_YSG.css +1 -0
  42. package/src/ui/public/assets/office-docx-ZOPof2Jf.js +32 -0
  43. package/src/ui/public/assets/{office-excel-CgdzbtOn.js → office-excel-DxreApmb.js} +1 -1
  44. package/src/ui/public/assets/office-pptx-CR0bf9zY.js +74 -0
  45. package/src/ui/public/assets/toolsStore-DsvD8S_8.js +1 -0
  46. package/src/ui/public/assets/{vendor-FHXYVNcm.js → vendor-I3yQCqJK.js} +46 -39
  47. package/src/ui/public/assets/vue-flow-DV5FYL9P.js +39 -0
  48. package/src/ui/public/index.html +13 -13
  49. package/src/ui/server/routes/fileOpen.js +248 -20
  50. package/src/ui/server/routes/fileOpen.test.js +239 -0
  51. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +0 -1
  52. package/src/ui/public/assets/AgentView-DnxCoGA3.js +0 -4
  53. package/src/ui/public/assets/AppVersionBadge-BYHep72-.js +0 -2
  54. package/src/ui/public/assets/AppVersionBadge-Dj6H729N.css +0 -1
  55. package/src/ui/public/assets/BranchSelector-CxDWJWY9.js +0 -1
  56. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +0 -1
  57. package/src/ui/public/assets/CommitForm-CUvl0VcF.css +0 -1
  58. package/src/ui/public/assets/CommitForm-uOwUV97K.js +0 -6
  59. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  60. package/src/ui/public/assets/CommonDialog-Zoo8fqjA.js +0 -1
  61. package/src/ui/public/assets/EditorView-B2lxPjuX.js +0 -1
  62. package/src/ui/public/assets/EditorView-BgA-_8WV.css +0 -1
  63. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +0 -1
  64. package/src/ui/public/assets/FlowExecutionViewer-BUAzBEte.js +0 -1
  65. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +0 -1
  66. package/src/ui/public/assets/FlowOrchestrationWorkspace-Ca4ROV6g.js +0 -21
  67. package/src/ui/public/assets/LogList-CLcg4XNV.js +0 -7
  68. package/src/ui/public/assets/LogList-r8sgFBhZ.css +0 -1
  69. package/src/ui/public/assets/MindmapView-B-w2CfCF.js +0 -1
  70. package/src/ui/public/assets/MindmapView-CS2t30Ko.css +0 -1
  71. package/src/ui/public/assets/MonitorView-DxFogQIt.css +0 -1
  72. package/src/ui/public/assets/MonitorView-RhIHMXtT.js +0 -1
  73. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +0 -1
  74. package/src/ui/public/assets/ProjectStartupButton-D1woOmfK.js +0 -1
  75. package/src/ui/public/assets/RemoteRepoCard-C00T4duQ.js +0 -1
  76. package/src/ui/public/assets/RemoteRepoCard-d0klonVb.css +0 -1
  77. package/src/ui/public/assets/SourceMapView-BlGFU0dY.js +0 -3
  78. package/src/ui/public/assets/SourceMapView-DG_a_076.css +0 -1
  79. package/src/ui/public/assets/SvgIcon-DniMQk5U.js +0 -1
  80. package/src/ui/public/assets/UserInputNode-D83u028f.js +0 -1
  81. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +0 -1
  82. package/src/ui/public/assets/WorkbenchView-BhZcJVl4.js +0 -12
  83. package/src/ui/public/assets/element-plus-Cchqf1s7.js +0 -19
  84. package/src/ui/public/assets/index-CwgZxqFI.js +0 -46
  85. package/src/ui/public/assets/index-DLi15Rb_.css +0 -1
  86. package/src/ui/public/assets/office-docx-BHL8ws1l.js +0 -31
  87. package/src/ui/public/assets/office-pptx-DyxMcWQh.js +0 -60
  88. package/src/ui/public/assets/toolsStore-C0VjU2dD.js +0 -1
  89. package/src/ui/public/assets/vue-flow-3jJr-V2W.js +0 -39
@@ -15,6 +15,7 @@
15
15
  import fs from 'fs/promises';
16
16
  import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
17
17
  import path from 'path';
18
+ import os from 'os';
18
19
  import open from 'open';
19
20
  import { spawn, spawnSync } from 'child_process';
20
21
 
@@ -44,6 +45,61 @@ function commandExists(command, platform = process.platform) {
44
45
  return result.status === 0;
45
46
  }
46
47
 
48
+ /**
49
+ * 从 `--version` 的输出里提取 semver 版本号。
50
+ * 各工具输出格式五花八门:
51
+ * dsh → "0.1.1-rc.2"
52
+ * claude → "1.0.60 (Claude Code)"
53
+ * code → "1.92.0 abc123def (commit ...)"
54
+ * opencode → "opencode 0.1.30"
55
+ * 策略:取第一个 semver 模式(含 prerelease 0.1.1-rc.2 / build 1.0.0+build.1)。
56
+ * 提不出来返回 null(调用方按"未知版本"处理,不影响安装判定)。
57
+ */
58
+ export function parseVersionOutput(text) {
59
+ if (!text) return null;
60
+ const match = String(text).match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/);
61
+ return match ? match[0] : null;
62
+ }
63
+
64
+ // ── npm registry 最新版本查询(右键更新菜单的「当前 → 最新」提示用)──────────
65
+ // 为什么走 HTTP 而不是 `npm view <pkg> version`:spawnSync('npm.cmd', ...) 在
66
+ // server 子进程里不稳(prefix -g 曾返回 "…npm:3" 废地址,见 findDshExecutable
67
+ // 的修复记录),HTTP 直查又快又可控。
68
+ // registry 优先级:~/.npmrc 的 registry 字段(用户可能配了镜像)→ npmmirror 默认。
69
+
70
+ let cachedRegistryBase = null;
71
+
72
+ export function getRegistryBase(readFile = fs.readFile) {
73
+ if (cachedRegistryBase) return cachedRegistryBase;
74
+ return readFile(path.join(os.homedir(), '.npmrc'), 'utf8').then((npmrc) => {
75
+ const match = String(npmrc).match(/^\s*registry\s*=\s*(\S+)/m);
76
+ cachedRegistryBase = match
77
+ ? match[1].replace(/\/+$/, '')
78
+ : 'https://registry.npmmirror.com';
79
+ return cachedRegistryBase;
80
+ }).catch(() => (cachedRegistryBase = 'https://registry.npmmirror.com'));
81
+ }
82
+
83
+ export async function fetchLatestVersion(packageName, { timeoutMs = 8000, fetchImpl = fetch } = {}) {
84
+ if (!packageName || !/^[\w@/.-]+$/.test(packageName)) return null;
85
+ const base = await getRegistryBase();
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
88
+ try {
89
+ const resp = await fetchImpl(`${base}/${packageName}/latest`, {
90
+ signal: controller.signal,
91
+ headers: { accept: 'application/json' },
92
+ });
93
+ if (!resp.ok) return null;
94
+ const data = await resp.json();
95
+ return typeof data?.version === 'string' ? data.version : null;
96
+ } catch {
97
+ return null; // 网络失败/超时:菜单显示原文案,不影响任何功能
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+
47
103
  /**
48
104
  * 返回当前平台可展示、可执行的安装方案。
49
105
  * executable/args 只在服务端使用;前端只能提交固定 tool id,不能提交命令。
@@ -64,6 +120,12 @@ export function getToolInstallers(platform = process.platform, hasCommand = comm
64
120
  : '未检测到 npm,请先安装 Node.js/npm,或按照官方文档手动安装。',
65
121
  executable: npmExecutable,
66
122
  args: ['install', '-g', packageName],
123
+ // 升级变体:@latest 强制同步到最新版。已是最新时 npm 自然跳过。
124
+ updateCommand: `npm install -g ${packageName}@latest`,
125
+ updatePackageManager: 'npm',
126
+ updateNote: '将在新终端中执行 `npm install -g <pkg>@latest`。已是最新时 npm 会跳过。',
127
+ updateExecutable: npmExecutable,
128
+ updateArgs: ['install', '-g', `${packageName}@latest`],
67
129
  };
68
130
  }
69
131
 
@@ -79,6 +141,18 @@ export function getToolInstallers(platform = process.platform, hasCommand = comm
79
141
  : '未检测到 winget,请使用 VS Code 官方安装程序。',
80
142
  executable: 'winget',
81
143
  args: ['install', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements'],
144
+ // winget 的升级动词是 upgrade(不是 install)
145
+ updateCommand: wingetAvailable
146
+ ? 'winget upgrade --id Microsoft.VisualStudioCode -e --accept-package-agreements --accept-source-agreements'
147
+ : undefined,
148
+ updatePackageManager: 'winget',
149
+ updateNote: wingetAvailable
150
+ ? '将在新终端中通过 winget upgrade 升级 VS Code。'
151
+ : '未检测到 winget,请使用 VS Code 官方安装包。',
152
+ updateExecutable: wingetAvailable ? 'winget' : undefined,
153
+ updateArgs: wingetAvailable
154
+ ? ['upgrade', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements']
155
+ : undefined,
82
156
  };
83
157
  } else if (platform === 'darwin') {
84
158
  const brewAvailable = hasCommand('brew', platform);
@@ -92,6 +166,11 @@ export function getToolInstallers(platform = process.platform, hasCommand = comm
92
166
  : '未检测到 Homebrew,请使用 VS Code 官方安装程序。',
93
167
  executable: 'brew',
94
168
  args: ['install', '--cask', 'visual-studio-code'],
169
+ updateCommand: brewAvailable ? 'brew upgrade --cask visual-studio-code' : undefined,
170
+ updatePackageManager: 'Homebrew',
171
+ updateNote: brewAvailable ? '将在新终端中通过 brew upgrade 升级 VS Code。' : '未检测到 brew。',
172
+ updateExecutable: brewAvailable ? 'brew' : undefined,
173
+ updateArgs: brewAvailable ? ['upgrade', '--cask', 'visual-studio-code'] : undefined,
95
174
  };
96
175
  } else {
97
176
  const snapAvailable = hasCommand('snap', platform);
@@ -105,6 +184,12 @@ export function getToolInstallers(platform = process.platform, hasCommand = comm
105
184
  : '当前 Linux 环境未检测到 snap,请按照发行版对应的官方说明安装。',
106
185
  executable: 'sudo',
107
186
  args: ['snap', 'install', 'code', '--classic'],
187
+ // snap 的升级动词是 refresh
188
+ updateCommand: snapAvailable ? 'sudo snap refresh code' : undefined,
189
+ updatePackageManager: 'snap',
190
+ updateNote: snapAvailable ? '将在新终端中通过 snap refresh 升级 VS Code。' : '未检测到 snap。',
191
+ updateExecutable: snapAvailable ? 'sudo' : undefined,
192
+ updateArgs: snapAvailable ? ['snap', 'refresh', 'code'] : undefined,
108
193
  };
109
194
  }
110
195
 
@@ -130,6 +215,26 @@ export function getToolInstallers(platform = process.platform, hasCommand = comm
130
215
  'Invoke-RestMethod https://code.kimi.com/kimi-code/install.ps1 | Invoke-Expression',
131
216
  ].join('; ')
132
217
  : undefined,
218
+ // 升级:kimi 官方脚本幂等,已安装时执行会自动升级到最新版,直接复用同一脚本
219
+ updateCommand: platform === 'win32'
220
+ ? 'irm https://code.kimi.com/kimi-code/install.ps1 | iex'
221
+ : 'curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash',
222
+ updatePackageManager: 'Kimi Code 官方安装脚本',
223
+ updateNote: '官方安装脚本幂等,已安装时执行会自动升级到最新版本。',
224
+ updateKind: 'script',
225
+ updateExecutionCommand: platform === 'win32'
226
+ ? [
227
+ "if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {",
228
+ "function global:Get-FileHash { param([string]$Path, [string]$Algorithm = 'SHA256')",
229
+ "$stream = [System.IO.File]::OpenRead($Path)",
230
+ "try { $sha = [System.Security.Cryptography.SHA256]::Create(); try { $bytes = $sha.ComputeHash($stream) } finally { $sha.Dispose() } } finally { $stream.Dispose() }",
231
+ "$hash = ([System.BitConverter]::ToString($bytes)).Replace('-', '')",
232
+ "New-Object PSObject -Property @{ Algorithm = 'SHA256'; Hash = $hash; Path = $Path }",
233
+ "}",
234
+ "}",
235
+ 'Invoke-RestMethod https://code.kimi.com/kimi-code/install.ps1 | Invoke-Expression',
236
+ ].join('; ')
237
+ : undefined,
133
238
  };
134
239
  installers.zcode = {
135
240
  supported: false,
@@ -137,6 +242,10 @@ export function getToolInstallers(platform = process.platform, hasCommand = comm
137
242
  packageManager: 'ZCode 官方安装包',
138
243
  docsUrl: TOOL_DOCS_URLS.zcode,
139
244
  note: 'ZCode 是桌面应用,官方提供 Windows、macOS 和 Linux 安装包,没有官方 npm CLI。',
245
+ // ZCode 是桌面应用、没有 CLI 更新通道;/api/update-tool 会对它返回 400
246
+ updateCommand: undefined,
247
+ updatePackageManager: 'ZCode 官方安装包',
248
+ updateNote: 'ZCode 是桌面应用,升级请到官网下载新版安装包覆盖安装。',
140
249
  };
141
250
  return installers;
142
251
  }
@@ -283,17 +392,34 @@ async function launchDsh(dirPath) {
283
392
  return launchInTerminal(dirPath, executable, ['web']);
284
393
  }
285
394
 
286
- async function findDshExecutable() {
395
+ // Windows 上找 dsh 可执行文件。
396
+ // 优先用 where.exe dsh(覆盖 nvm4w 的 C:\nvm4w\nodejs\、默认的 %APPDATA%\npm、
397
+ // pnpm/yarn 自定义 prefix、以及用户手摆到 PATH 里的任意位置),
398
+ //再兜底 APPDATA\npm + npm prefix -g 两个写死路径(为 where.exe 漏检的边缘情况,
399
+ //比如 PATH 里只有 junction/symlink 时)。
400
+ export async function findDshExecutable() {
287
401
  if (process.platform !== 'win32') return commandExists('dsh') ? 'dsh' : null;
288
402
 
289
- const candidates = [path.join(process.env.APPDATA || '', 'npm', 'dsh.cmd')];
403
+ const where = spawnSync('where.exe', ['dsh'], { encoding: 'utf8', windowsHide: true });
404
+ if (where.status === 0 && where.stdout) {
405
+ for (const line of where.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
406
+ // 偶尔会输出 "PATH=..." 这种变量行,跳过
407
+ if (/^[A-Z_]+=/i.test(line)) continue;
408
+ try {
409
+ const stat = await fs.stat(line);
410
+ if (stat.isFile()) return line;
411
+ } catch {}
412
+ }
413
+ }
414
+
415
+ const fallback = [path.join(process.env.APPDATA || '', 'npm', 'dsh.cmd')];
290
416
  const npmPrefix = spawnSync('npm.cmd', ['prefix', '-g'], {
291
417
  encoding: 'utf8',
292
418
  windowsHide: true,
293
419
  }).stdout?.trim();
294
- if (npmPrefix) candidates.push(path.join(npmPrefix, 'dsh.cmd'));
420
+ if (npmPrefix) fallback.push(path.join(npmPrefix, 'dsh.cmd'));
295
421
 
296
- for (const candidate of candidates.filter(Boolean)) {
422
+ for (const candidate of fallback.filter(Boolean)) {
297
423
  try {
298
424
  const stat = await fs.stat(candidate);
299
425
  if (stat.isFile()) return candidate;
@@ -390,27 +516,35 @@ async function findZCodeExecutable() {
390
516
  return null;
391
517
  }
392
518
 
393
- async function launchToolInstaller(installer, dirPath = process.cwd()) {
394
- if (installer.kind === 'script') {
519
+ async function launchToolInstaller(installer, dirPath = process.cwd(), { update = false } = {}) {
520
+ // update=true 时改用 updateXxx 字段(升级命令与安装命令动词不同:
521
+ // npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本幂等重跑)
522
+ const kindField = update ? 'updateKind' : 'kind';
523
+ const execCmdField = update ? 'updateExecutionCommand' : 'executionCommand';
524
+ const cmdField = update ? 'updateCommand' : 'command';
525
+ const executableField = update ? 'updateExecutable' : 'executable';
526
+ const argsField = update ? 'updateArgs' : 'args';
527
+
528
+ if (installer[kindField] === 'script') {
395
529
  if (process.platform === 'win32') {
396
530
  // 显式创建新控制台;编码脚本可避免 cmd.exe 截获 PowerShell 管道符。
397
- const encoded = Buffer.from(installer.executionCommand || installer.command, 'utf16le').toString('base64');
531
+ const encoded = Buffer.from(installer[execCmdField] || installer[cmdField], 'utf16le').toString('base64');
398
532
  return spawnDetached('cmd.exe', [
399
533
  '/c', 'start', '""', 'powershell.exe', '-NoLogo', '-NoExit',
400
534
  '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
401
535
  ], { cwd: dirPath });
402
536
  }
403
- return launchInTerminal(dirPath, 'bash', ['-lc', installer.command]);
537
+ return launchInTerminal(dirPath, 'bash', ['-lc', installer[cmdField]]);
404
538
  }
405
539
  if (process.platform === 'win32') {
406
- // 安装命令完全来自服务端白名单。使用可见 cmd 窗口,让用户看到进度和错误。
407
- const commandLine = [installer.executable, ...installer.args].join(' ');
540
+ // 安装/更新命令完全来自服务端白名单。使用可见 cmd 窗口,让用户看到进度和错误。
541
+ const commandLine = [installer[executableField], ...installer[argsField]].join(' ');
408
542
  return spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', commandLine], {
409
543
  cwd: dirPath,
410
544
  });
411
545
  }
412
546
 
413
- return launchInTerminal(dirPath, installer.executable, installer.args);
547
+ return launchInTerminal(dirPath, installer[executableField], installer[argsField]);
414
548
  }
415
549
 
416
550
  export function registerFileOpenRoutes({
@@ -752,30 +886,113 @@ export function registerFileOpenRoutes({
752
886
  });
753
887
  }));
754
888
 
755
- // 检测本地工具是否已安装(供前端根据结果决定是否显示对应按钮)
756
- // 检测方式: spawn 'tool --version',exit 0 即视为已安装
889
+ // 更新本地工具:与 install-tool 同一套白名单,只是换用 updateXxx 命令变体
890
+ // (npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本重跑)。
891
+ // 没有 updateCommand 的工具(如 zcode 桌面应用)返回 400。
892
+ app.post('/api/update-tool', asyncRoute(async (req, res) => {
893
+ const { tool } = req.body || {};
894
+ const installers = getToolInstallers();
895
+ const installer = installers[tool];
896
+
897
+ if (!installer) {
898
+ throw new HttpError(400, '不支持的工具');
899
+ }
900
+ if (!installer.updateCommand) {
901
+ throw new HttpError(400, installer.updateNote || '当前工具不支持一键更新,请按官方文档手动升级');
902
+ }
903
+
904
+ await launchToolInstaller(installer, process.cwd(), { update: true });
905
+ res.json({
906
+ success: true,
907
+ message: '更新命令已在新终端中启动',
908
+ });
909
+ }));
910
+
911
+ // 查询 npm registry 上的最新版本,供前端更新菜单显示「当前 v1 → 最新 v2」。
912
+ // 只支持 TOOL_INSTALL_PACKAGES 里的 npm 工具;网络失败的工具返回 null(前端降级为原文案)。
913
+ // body 可选 { tools: ['claude', ...] } —— 缺省查全部 npm 工具。
914
+ app.post('/api/latest-tool-versions', asyncRoute(async (req, res) => {
915
+ const { tools } = req.body || {};
916
+ let requested;
917
+ if (tools === undefined) {
918
+ requested = Object.keys(TOOL_INSTALL_PACKAGES);
919
+ } else {
920
+ if (!Array.isArray(tools) || tools.some((t) => typeof t !== 'string')) {
921
+ throw new HttpError(400, 'tools 必须是字符串数组');
922
+ }
923
+ const unknown = tools.filter((t) => !TOOL_INSTALL_PACKAGES[t]);
924
+ if (unknown.length) {
925
+ throw new HttpError(400, `不支持的工具: ${unknown.join(', ')}`);
926
+ }
927
+ requested = tools;
928
+ }
929
+
930
+ const entries = await Promise.all(
931
+ requested.map(async (tool) => [tool, await fetchLatestVersion(TOOL_INSTALL_PACKAGES[tool])]),
932
+ );
933
+ res.json({
934
+ success: true,
935
+ latest: Object.fromEntries(entries),
936
+ });
937
+ }));
938
+
939
+ // 检测本地工具是否已安装 + 采集版本号(供前端决定按钮行为、tooltip 显示版本)
940
+ // 检测方式: spawn 'tool --version',exit 0 即视为已安装;stdout/stderr 里提取 semver。
757
941
  // 超时 15s:Claude CLI / opencode 等 Node 包装的工具首次冷启动
758
942
  // 在 Windows 上经常 5-7s(磁盘 cache + Defender 扫描),3s 永远命中超时。
759
943
  app.get('/api/check-tools', asyncRoute(async (req, res) => {
760
944
  const checkCmd = (cmd) => new Promise((resolve) => {
761
945
  const child = spawn(cmd, ['--version'], {
762
- stdio: 'ignore',
946
+ // 要捕获输出来解析版本号,不能再用 'ignore'
947
+ stdio: ['ignore', 'pipe', 'pipe'],
763
948
  shell: process.platform === 'win32',
764
949
  windowsHide: true,
765
950
  });
951
+ let output = '';
766
952
  let done = false;
767
953
  const finish = (ok) => {
768
954
  if (done) return;
769
955
  done = true;
770
956
  try { child.kill('SIGKILL'); } catch {}
771
- resolve(ok);
957
+ resolve({ installed: ok, version: parseVersionOutput(output) });
772
958
  };
959
+ // 有些 CLI 把版本号打到 stderr(如部分 Node 包装工具),两边都收
960
+ child.stdout.on('data', (chunk) => { output += chunk; });
961
+ child.stderr.on('data', (chunk) => { output += chunk; });
773
962
  child.on('error', () => finish(false));
774
963
  child.on('exit', (code) => finish(code === 0));
775
964
  setTimeout(() => finish(false), 15000);
776
965
  });
777
966
 
778
- const [vscode, claude, codex, opencode, kimiExecutable, zcodeExecutable, dshExecutable] = await Promise.all([
967
+ // dsh / kimi find*Executable 拿到完整路径,再补一次 --version 采版本。
968
+ // zcode 是纯 GUI 桌面应用,ZCode.exe --version 行为未知(可能弹窗),不采集。
969
+ const versionOfExecutable = (executable) => {
970
+ if (!executable) return Promise.resolve(null);
971
+ return new Promise((resolve) => {
972
+ const child = spawn(executable, ['--version'], {
973
+ stdio: ['ignore', 'pipe', 'pipe'],
974
+ shell: process.platform === 'win32',
975
+ windowsHide: true,
976
+ });
977
+ let output = '';
978
+ let done = false;
979
+ const finish = () => {
980
+ if (done) return;
981
+ done = true;
982
+ try { child.kill('SIGKILL'); } catch {}
983
+ resolve(parseVersionOutput(output));
984
+ };
985
+ child.stdout.on('data', (chunk) => { output += chunk; });
986
+ child.stderr.on('data', (chunk) => { output += chunk; });
987
+ child.on('error', () => finish());
988
+ child.on('exit', () => finish());
989
+ setTimeout(() => finish(), 15000);
990
+ });
991
+ };
992
+
993
+ // dsh / kimi 的版本采集依赖 find 结果,用 then 链接保持全并行
994
+ // (find 会跑两次——一次拿路径一次链版本——都是纯 fs/注册表查询,开销可忽略)
995
+ const [vscodeRes, claudeRes, codexRes, opencodeRes, kimiExecutable, zcodeExecutable, dshExecutable, kimiVersion, dshVersion] = await Promise.all([
779
996
  checkCmd('code'),
780
997
  checkCmd('claude'),
781
998
  checkCmd('codex'),
@@ -783,6 +1000,8 @@ export function registerFileOpenRoutes({
783
1000
  findKimiExecutable(),
784
1001
  findZCodeExecutable(),
785
1002
  findDshExecutable(),
1003
+ findKimiExecutable().then(versionOfExecutable),
1004
+ findDshExecutable().then(versionOfExecutable),
786
1005
  ]);
787
1006
 
788
1007
  const installers = getToolInstallers();
@@ -790,13 +1009,22 @@ export function registerFileOpenRoutes({
790
1009
  success: true,
791
1010
  platform: process.platform,
792
1011
  installers: publicInstallerInfo(installers),
793
- vscode,
794
- claude,
795
- codex,
796
- opencode,
1012
+ vscode: vscodeRes.installed,
1013
+ claude: claudeRes.installed,
1014
+ codex: codexRes.installed,
1015
+ opencode: opencodeRes.installed,
797
1016
  kimi: !!kimiExecutable,
798
1017
  zcode: !!zcodeExecutable,
799
1018
  dsh: !!dshExecutable,
1019
+ versions: {
1020
+ vscode: vscodeRes.version,
1021
+ claude: claudeRes.version,
1022
+ codex: codexRes.version,
1023
+ opencode: opencodeRes.version,
1024
+ kimi: kimiExecutable ? kimiVersion : null,
1025
+ zcode: null, // 桌面应用,无 CLI 版本通道
1026
+ dsh: dshExecutable ? dshVersion : null,
1027
+ },
800
1028
  });
801
1029
  }));
802
1030
  }
@@ -0,0 +1,239 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // findDshExecutable 回归测试(2026-09-05)
16
+ // 旧实现只查 %APPDATA%\npm + npm prefix -g,在 nvm4w 用户(全局包在
17
+ // C:\nvm4w\nodejs\) 上"未安装"误报。改用 where.exe dsh 优先后,这里守住
18
+ // "返回的路径必须真实存在"这条契约。
19
+ import { test } from 'node:test'
20
+ import assert from 'node:assert/strict'
21
+ import fs from 'node:fs/promises'
22
+ import { fetchLatestVersion, findDshExecutable, getToolInstallers, parseVersionOutput, registerFileOpenRoutes } from './fileOpen.js'
23
+
24
+ // ── parseVersionOutput(版本号采集,2026-09-05 check-tools 增强)────────────
25
+
26
+ test('parseVersionOutput: 各工具的 --version 输出都能提取 semver', () => {
27
+ assert.equal(parseVersionOutput('0.1.1-rc.2\n'), '0.1.1-rc.2') // dsh(带 prerelease)
28
+ assert.equal(parseVersionOutput('1.0.60 (Claude Code)'), '1.0.60') // claude
29
+ assert.equal(parseVersionOutput('1.92.0 abc123def (commit)\n'), '1.92.0') // vscode
30
+ assert.equal(parseVersionOutput('opencode 0.1.30\n'), '0.1.30') // opencode 前缀
31
+ assert.equal(parseVersionOutput('codex-cli 0.9.2+build.1'), '0.9.2+build.1') // build 元数据
32
+ })
33
+
34
+ test('parseVersionOutput: 边界输入返回 null', () => {
35
+ assert.equal(parseVersionOutput(''), null)
36
+ assert.equal(parseVersionOutput(null), null)
37
+ assert.equal(parseVersionOutput(undefined), null)
38
+ assert.equal(parseVersionOutput('no version here'), null)
39
+ // 多段数字但不是 semver(如日期 2026.09.05 会被提取——可接受,总比没有强)
40
+ assert.ok(parseVersionOutput('version: 2.16.42\nsomething 1.0.0').startsWith('2.16.42'))
41
+ })
42
+
43
+ test('findDshExecutable: Windows 上返回 string 或 null,且非 null 时路径必须存在', async () => {
44
+ const result = await findDshExecutable()
45
+ if (process.platform !== 'win32') {
46
+ // 非 win32 平台走 commandExists('dsh') 路径:要么是 'dsh'(which 命中),
47
+ // 要么是 null。不强求特定值,只断言类型。
48
+ assert.ok(result === null || typeof result === 'string')
49
+ return
50
+ }
51
+ if (result !== null) {
52
+ assert.equal(typeof result, 'string')
53
+ // 核心契约:返回的路径必须真实存在且是文件。
54
+ // 这正是这次修复要解决的:不允许再出现"返回看似合法但 fs.stat 不到的路径"
55
+ // (旧实现里 APPDATA/npm/dsh.cmd + npm prefix -g 拼出的废地址都是 fs.stat 不到的)。
56
+ const stat = await fs.stat(result)
57
+ assert.equal(stat.isFile(), true, `${result} 不是文件`)
58
+ }
59
+ })
60
+
61
+ // ── 工具升级(updateCommand)──────────────────────────────────────────
62
+ // 2026-09-05 新增:右键工具 → 更新。install 与 update 命令动词不同
63
+ // (npm @latest / winget upgrade / brew upgrade / snap refresh / kimi 脚本重跑)。
64
+
65
+ test('getToolInstallers: npm 包工具的升级变体带 @latest', () => {
66
+ const installers = getToolInstallers('win32', () => true)
67
+ for (const tool of ['claude', 'codex', 'opencode', 'dsh']) {
68
+ const inst = installers[tool]
69
+ assert.match(inst.updateCommand, /@latest$/, `${tool}.updateCommand 应带 @latest`)
70
+ assert.ok(inst.updateExecutable, `${tool}.updateExecutable 缺失`)
71
+ assert.ok(
72
+ inst.updateArgs.at(-1).endsWith('@latest'),
73
+ `${tool}.updateArgs 最后一项应带 @latest`,
74
+ )
75
+ // 安装命令不被升级字段污染
76
+ assert.doesNotMatch(inst.command, /@latest/, `${tool}.command 不应带 @latest`)
77
+ }
78
+ })
79
+
80
+ test('getToolInstallers: 各平台 vscode 升级用各自的 upgrade 动词', () => {
81
+ const win = getToolInstallers('win32', () => true)
82
+ assert.match(win.vscode.updateCommand, /^winget upgrade/)
83
+ assert.deepEqual(win.vscode.updateArgs.slice(0, 2), ['upgrade', '--id'])
84
+
85
+ const mac = getToolInstallers('darwin', () => true)
86
+ assert.match(mac.vscode.updateCommand, /^brew upgrade/)
87
+
88
+ const linux = getToolInstallers('linux', () => true)
89
+ assert.match(linux.vscode.updateCommand, /^sudo snap refresh/)
90
+ })
91
+
92
+ test('getToolInstallers: kimi 升级复用幂等脚本,zcode 不支持一键升级', () => {
93
+ const installers = getToolInstallers('win32', () => true)
94
+ assert.equal(installers.kimi.updateKind, 'script')
95
+ assert.match(installers.kimi.updateCommand, /install\.ps1/)
96
+ // zcode 是桌面应用,updateCommand 必须为空 → /api/update-tool 对它返回 400
97
+ assert.equal(installers.zcode.updateCommand, undefined)
98
+ })
99
+
100
+ test('update-tool 路由: zcode(无 updateCommand)返回 400 且不启动任何进程', async () => {
101
+ // zcode 的 400 在 launchToolInstaller 之前抛出,本测试不会真的打开终端窗口。
102
+ const routes = new Map()
103
+ const app = {
104
+ get(path, handler) { routes.set(`GET ${path}`, handler) },
105
+ post(path, handler) { routes.set(`POST ${path}`, handler) },
106
+ }
107
+ registerFileOpenRoutes({ app })
108
+ const handler = routes.get('POST /api/update-tool')
109
+ assert.ok(handler, 'POST /api/update-tool 路由未注册')
110
+
111
+ let statusCode = 200
112
+ let payload = null
113
+ const res = {
114
+ status(code) { statusCode = code; return this },
115
+ json(value) { payload = value; return this },
116
+ }
117
+ await handler({ method: 'POST', path: '/api/update-tool', body: { tool: 'zcode' } }, res, () => {})
118
+
119
+ assert.equal(statusCode, 400)
120
+ assert.equal(payload.success, false)
121
+ assert.match(payload.error, /不支持一键更新|桌面应用/)
122
+ })
123
+
124
+ test('update-tool 路由: 未知工具 id 返回 400', async () => {
125
+ const routes = new Map()
126
+ const app = {
127
+ get(path, handler) { routes.set(`GET ${path}`, handler) },
128
+ post(path, handler) { routes.set(`POST ${path}`, handler) },
129
+ }
130
+ registerFileOpenRoutes({ app })
131
+ const handler = routes.get('POST /api/update-tool')
132
+
133
+ let statusCode = 200
134
+ let payload = null
135
+ const res = {
136
+ status(code) { statusCode = code; return this },
137
+ json(value) { payload = value; return this },
138
+ }
139
+ await handler({ method: 'POST', path: '/api/update-tool', body: { tool: 'not-a-tool' } }, res, () => {})
140
+
141
+ assert.equal(statusCode, 400)
142
+ assert.equal(payload.success, false)
143
+ })
144
+
145
+ // ── fetchLatestVersion / latest-tool-versions(2026-09-05 更新菜单「当前→最新」)──
146
+
147
+ test('fetchLatestVersion: 非法包名直接返回 null,不发请求', async () => {
148
+ let fetchCalls = 0
149
+ const fakeFetch = async () => { fetchCalls++; return { ok: true, json: async () => ({ version: '1.0.0' }) } }
150
+ assert.equal(await fetchLatestVersion(''), null)
151
+ assert.equal(await fetchLatestVersion(null), null)
152
+ assert.equal(await fetchLatestVersion('bad pkg name with spaces'), null)
153
+ assert.equal(await fetchLatestVersion('../etc/passwd'), null)
154
+ assert.equal(fetchCalls, 0, '非法包名不应触发任何网络请求')
155
+ })
156
+
157
+ test('fetchLatestVersion: 命中 registry 返回 version;非 200 / 抛异常 / 字段缺失都降级为 null', async () => {
158
+ const okFetch = async () => ({ ok: true, json: async () => ({ version: '2.3.4' }) })
159
+ assert.equal(await fetchLatestVersion('opencode-ai', { fetchImpl: okFetch }), '2.3.4')
160
+
161
+ const notOkFetch = async () => ({ ok: false, json: async () => ({}) })
162
+ assert.equal(await fetchLatestVersion('opencode-ai', { fetchImpl: notOkFetch }), null)
163
+
164
+ const throwFetch = async () => { throw new Error('network down') }
165
+ assert.equal(await fetchLatestVersion('opencode-ai', { fetchImpl: throwFetch }), null)
166
+
167
+ const missingFieldFetch = async () => ({ ok: true, json: async () => ({ name: 'opencode-ai' }) })
168
+ assert.equal(await fetchLatestVersion('opencode-ai', { fetchImpl: missingFieldFetch }), null)
169
+ })
170
+
171
+ test('latest-tool-versions 路由: tools 非数组 / 含未知工具 → 400,且不发请求', async () => {
172
+ const routes = new Map()
173
+ const app = {
174
+ get(path, handler) { routes.set(`GET ${path}`, handler) },
175
+ post(path, handler) { routes.set(`POST ${path}`, handler) },
176
+ }
177
+ registerFileOpenRoutes({ app })
178
+ const handler = routes.get('POST /api/latest-tool-versions')
179
+ assert.ok(handler, 'POST /api/latest-tool-versions 路由未注册')
180
+
181
+ const makeRes = () => {
182
+ let statusCode = 200
183
+ let payload = null
184
+ return {
185
+ res: { status(code) { statusCode = code; return this }, json(value) { payload = value; return this } },
186
+ get status() { return statusCode },
187
+ get payload() { return payload },
188
+ }
189
+ }
190
+
191
+ let ctx = makeRes()
192
+ await handler({ method: 'POST', body: { tools: 'claude' } }, ctx.res, () => {})
193
+ assert.equal(ctx.status, 400)
194
+
195
+ ctx = makeRes()
196
+ await handler({ method: 'POST', body: { tools: [123] } }, ctx.res, () => {})
197
+ assert.equal(ctx.status, 400)
198
+
199
+ ctx = makeRes()
200
+ await handler({ method: 'POST', body: { tools: ['claude', 'zcode'] } }, ctx.res, () => {})
201
+ // zcode 不在 TOOL_INSTALL_PACKAGES(npm 工具白名单)里 → 400
202
+ assert.equal(ctx.status, 400)
203
+ assert.match(ctx.payload.error, /不支持的工具/)
204
+ })
205
+
206
+ test('latest-tool-versions 路由: 正常路径调 registry 并把结果按 tool id 归还', async () => {
207
+ // 用全局 fetch mock 验证「路由 → fetchLatestVersion → registry URL」整条接线,
208
+ // 测试完必须恢复,不然会污染其它测试。
209
+ const requestedUrls = []
210
+ const originalFetch = globalThis.fetch
211
+ globalThis.fetch = async (url) => {
212
+ requestedUrls.push(String(url))
213
+ return { ok: true, json: async () => ({ version: '9.9.9' }) }
214
+ }
215
+ try {
216
+ const routes = new Map()
217
+ const app = {
218
+ get(path, handler) { routes.set(`GET ${path}`, handler) },
219
+ post(path, handler) { routes.set(`POST ${path}`, handler) },
220
+ }
221
+ registerFileOpenRoutes({ app })
222
+ const handler = routes.get('POST /api/latest-tool-versions')
223
+
224
+ let payload = null
225
+ const res = {
226
+ status() { return this },
227
+ json(value) { payload = value; return this },
228
+ }
229
+ await handler({ method: 'POST', body: { tools: ['claude', 'dsh'] } }, res, () => {})
230
+
231
+ assert.equal(payload.success, true)
232
+ assert.deepEqual(payload.latest, { claude: '9.9.9', dsh: '9.9.9' })
233
+ assert.equal(requestedUrls.length, 2)
234
+ assert.ok(requestedUrls[0].includes('/@anthropic-ai/claude-code/latest'), `URL 不对: ${requestedUrls[0]}`)
235
+ assert.ok(requestedUrls[1].includes('/@deepseek-ai/dsh/latest'), `URL 不对: ${requestedUrls[1]}`)
236
+ } finally {
237
+ globalThis.fetch = originalFetch
238
+ }
239
+ })
@@ -1 +0,0 @@
1
- .agent-view[data-v-773baa74]{background:var(--bg-container);height:100%;min-height:0;display:flex}.agent-sidebar[data-v-773baa74]{background:var(--bg-panel);border-right:1px solid var(--border-color);flex-direction:column;flex-shrink:0;display:flex;overflow:hidden}.sidebar-header[data-v-773baa74]{flex-shrink:0;padding:10px 12px}.new-session-btn[data-v-773baa74]{border:1px dashed var(--border-color);border-radius:var(--radius-md);width:100%;color:var(--text-secondary);cursor:pointer;background:0 0;justify-content:center;align-items:center;gap:6px;padding:8px 12px;font-family:inherit;font-size:13px;transition:all .15s;display:flex}.new-session-btn[data-v-773baa74]:hover{border-color:var(--color-primary);color:var(--color-primary);background:color-mix(in srgb, var(--color-primary) 6%, transparent)}.sidebar-search[data-v-773baa74]{flex-shrink:0;align-items:center;gap:6px;padding:0 12px 8px;display:flex}.sidebar-search .search-icon[data-v-773baa74]{color:var(--text-tertiary);flex-shrink:0}.sidebar-search .search-input[data-v-773baa74]{color:var(--text-primary);background:0 0;border:none;outline:none;flex:1;padding:4px 0;font-family:inherit;font-size:13px}.sidebar-search .search-input[data-v-773baa74]::placeholder{color:var(--text-tertiary)}.session-list[data-v-773baa74]{flex:1;min-height:0;padding:0 6px 8px;overflow-y:auto}.empty-state[data-v-773baa74]{text-align:center;color:var(--text-tertiary);flex-direction:column;justify-content:center;align-items:center;padding:48px 16px;display:flex}.empty-state .empty-text[data-v-773baa74]{color:var(--text-secondary);margin-top:10px;font-size:14px}.empty-state .empty-hint[data-v-773baa74]{margin-top:4px;font-size:12px}.session-item[data-v-773baa74]{border-radius:var(--radius-sm);cursor:pointer;align-items:flex-start;gap:4px;padding:8px 10px;transition:background .12s;display:flex;position:relative}.session-item[data-v-773baa74]:hover{background:var(--bg-hover)}.session-item:hover .session-item-actions[data-v-773baa74]{opacity:1}.session-item.active[data-v-773baa74]{background:color-mix(in srgb, var(--color-primary) 10%, transparent)}.session-item.active .session-item-title[data-v-773baa74]{color:var(--color-primary)}.session-item.active[data-v-773baa74]:before{content:"";background:var(--color-primary);border-radius:0 2px 2px 0;width:3px;height:20px;position:absolute;top:50%;left:0;transform:translateY(-50%)}.session-item-main[data-v-773baa74]{flex:1;min-width:0}.session-item-title[data-v-773baa74]{color:var(--text-primary);white-space:nowrap;text-overflow:ellipsis;margin-bottom:2px;font-size:13px;font-weight:500;overflow:hidden}.session-item-meta[data-v-773baa74]{color:var(--text-tertiary);align-items:center;gap:4px;font-size:11px;display:flex}.session-item-meta .meta-dot[data-v-773baa74]{opacity:.5}.session-item-meta .meta-source[data-v-773baa74]{border-radius:3px;padding:0 4px;font-size:10px;font-weight:600}.session-item-meta .meta-source.cli[data-v-773baa74]{background:color-mix(in srgb, var(--color-warning) 20%, transparent);color:var(--color-warning)}.session-item-actions[data-v-773baa74]{opacity:0;flex-shrink:0;gap:2px;transition:opacity .12s;display:flex}.item-action-btn[data-v-773baa74]{width:24px;height:24px;color:var(--text-tertiary);cursor:pointer;border-radius:var(--radius-xs);background:0 0;border:none;justify-content:center;align-items:center;padding:0;transition:all .12s;display:flex}.item-action-btn[data-v-773baa74]:hover{background:var(--bg-hover);color:var(--text-secondary)}.item-action-btn.danger[data-v-773baa74]:hover{color:var(--color-danger);background:color-mix(in srgb, var(--color-danger) 10%, transparent)}.sidebar-resizer[data-v-773baa74]{cursor:col-resize;background:0 0;flex-shrink:0;width:4px;transition:background .15s}.sidebar-resizer[data-v-773baa74]:hover,.sidebar-resizer.active[data-v-773baa74]{background:var(--color-primary);opacity:.3}.agent-chat-area[data-v-773baa74]{flex-direction:column;flex:1;min-width:0;display:flex;position:relative;overflow:hidden}.chat-loading[data-v-773baa74]{color:var(--text-tertiary);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:12px;font-size:14px;display:flex}.stop-button-bar[data-v-773baa74]{z-index:10;position:absolute;bottom:80px;left:50%;transform:translate(-50%)}.stop-button[data-v-773baa74]{border:1px solid var(--color-danger);background:var(--bg-container);color:var(--color-danger);cursor:pointer;border-radius:20px;align-items:center;gap:6px;padding:6px 16px;font-family:inherit;font-size:13px;transition:all .15s;display:flex;box-shadow:0 2px 8px #0000001f}.stop-button[data-v-773baa74]:hover{background:var(--color-danger);color:#fff}.fade-enter-active[data-v-773baa74],.fade-leave-active[data-v-773baa74]{transition:opacity .2s}.fade-enter-from[data-v-773baa74],.fade-leave-to[data-v-773baa74]{opacity:0}[data-theme=dark]{background:var(--bg-panel)}