dsh-x-core 0.1.26-rc.2 → 0.1.26-rc.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-x-core",
3
3
  "productName": "DSH",
4
- "version": "0.1.26-rc.2",
4
+ "version": "0.1.26-rc.4",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "server.js",
@@ -10,6 +10,7 @@
10
10
  * loader 钩子锚点匹配不到时(dsh 升级改了实现)没有 entry 时间线,但采样仍然有效。
11
11
  */
12
12
  import { Session } from 'node:inspector/promises'
13
+ import { registerHooks } from 'node:module'
13
14
  import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
14
15
  import { join } from 'node:path'
15
16
 
@@ -38,6 +39,48 @@ let startedAt = 0
38
39
  let timer = null
39
40
  let cap = null
40
41
  let stopped = false
42
+ /** 同步 loader 钩子:启动期解析/读取每个模块花的时间,按「谁触发」和「读的是谁」记。 */
43
+ let moduleHooks = null
44
+ const resolveCalls = []
45
+ const loadCalls = []
46
+ /** 启动期模块数不设死上限,但真有异常多的情况也不让数组无限长。 */
47
+ const MODULE_CALL_LIMIT = 100_000
48
+
49
+ function installModuleTiming() {
50
+ if (moduleHooks) return
51
+ try {
52
+ moduleHooks = registerHooks({
53
+ resolve(specifier, context, nextResolve) {
54
+ const started = performance.now()
55
+ const result = nextResolve(specifier, context)
56
+ if (resolveCalls.length < MODULE_CALL_LIMIT) {
57
+ resolveCalls.push({
58
+ parent: context.parentURL || '',
59
+ specifier: String(specifier),
60
+ url: String(result?.url || ''),
61
+ us: (performance.now() - started) * 1000,
62
+ })
63
+ }
64
+ return result
65
+ },
66
+ load(url, context, nextLoad) {
67
+ const started = performance.now()
68
+ const result = nextLoad(url, context)
69
+ if (loadCalls.length < MODULE_CALL_LIMIT) {
70
+ loadCalls.push({ url: String(url || ''), us: (performance.now() - started) * 1000 })
71
+ }
72
+ return result
73
+ },
74
+ })
75
+ } catch {
76
+ moduleHooks = null
77
+ }
78
+ }
79
+
80
+ function stopModuleTiming() {
81
+ try { moduleHooks?.deregister() } catch { /* ignore */ }
82
+ moduleHooks = null
83
+ }
41
84
 
42
85
  function packageOf(url, names) {
43
86
  const normalized = String(url || '').replace(/\\/g, '/')
@@ -53,11 +96,20 @@ function packageOf(url, names) {
53
96
  return '(其他)'
54
97
  }
55
98
 
56
- function summarize(cpuProfile, heapProfile) {
57
- const names = new Set(entries.map((row) => row.name).filter((name) => name && !name.startsWith('cordis:')))
99
+ export function summarize(cpuProfile, heapProfile, timing = { resolveCalls, loadCalls }, pluginNames = null) {
100
+ const names = new Set(pluginNames ?? entries.map((row) => row.name).filter((name) => name && !name.startsWith('cordis:')))
58
101
  const nodes = new Map((cpuProfile.nodes || []).map((node) => [node.id, node]))
102
+ // 没有 URL 的帧(open/readFileUtf8/spawn/GC…)往上找最近的 JS 调用方,
103
+ // 才能回答「这个插件在启动时到底占了多久」;找不到调用方的才记成未归属。
104
+ const parentOf = new Map()
105
+ for (const node of cpuProfile.nodes || []) {
106
+ // V8 的 children 里存的是节点 id(不是节点对象),按 id 建反向表。
107
+ for (const child of node.children || []) parentOf.set(child, node)
108
+ }
59
109
  const cpuUs = new Map()
110
+ const nativeByPackage = new Map()
60
111
  const nativeUs = new Map()
112
+ let nativeUnattributedUs = 0
61
113
  let totalUs = 0
62
114
  let idleUs = 0
63
115
  const samples = cpuProfile.samples || []
@@ -70,9 +122,18 @@ function summarize(cpuProfile, heapProfile) {
70
122
  if (fn === '(idle)' || fn === '(root)') { idleUs += delta; continue }
71
123
  const url = node?.callFrame?.url || ''
72
124
  if (!url) {
73
- // 没有 URL 的帧:GC、V8 编译/内置函数。单列出来,否则会盖住插件的账。
125
+ // 没有 URL 的帧:GC、V8 编译/内置函数。按调用方归属,同时保留系统调用分类。
74
126
  const key = fn || '(匿名原生)'
75
127
  nativeUs.set(key, (nativeUs.get(key) || 0) + delta)
128
+ let owner = parentOf.get(node?.id)
129
+ let ownerPkg = ''
130
+ while (owner) {
131
+ const ownerUrl = owner.callFrame?.url
132
+ if (ownerUrl) { ownerPkg = packageOf(ownerUrl, names); break }
133
+ owner = parentOf.get(owner.id)
134
+ }
135
+ if (ownerPkg) nativeByPackage.set(ownerPkg, (nativeByPackage.get(ownerPkg) || 0) + delta)
136
+ else nativeUnattributedUs += delta
76
137
  continue
77
138
  }
78
139
  const pkg = packageOf(url, names)
@@ -93,12 +154,85 @@ function summarize(cpuProfile, heapProfile) {
93
154
  }
94
155
  walk(heapProfile?.head)
95
156
 
96
- const packages = [...new Set([...cpuUs.keys(), ...allocBytes.keys()])].map((name) => ({
97
- name,
98
- cpuMs: Math.round((cpuUs.get(name) || 0) / 1000),
99
- allocKB: Math.round((allocBytes.get(name) || 0) / 1024),
100
- plugin: names.has(name),
101
- })).sort((a, b) => b.cpuMs - a.cpuMs)
157
+ // 模块加载:resolve 的耗时记在「谁触发」上,load 记在「读的是谁」上;
158
+ // resolve 同时给出 import 边,最后沿依赖图把整棵子树的加载时间归到插件。
159
+ const moduleUs = new Map()
160
+ const moduleEdges = new Map()
161
+ const moduleRow = (name) => {
162
+ let row = moduleUs.get(name)
163
+ if (!row) { row = { resolveUs: 0, loadUs: 0 }; moduleUs.set(name, row) }
164
+ return row
165
+ }
166
+ let moduleTotalUs = 0
167
+ for (const call of timing.resolveCalls || []) {
168
+ moduleTotalUs += call.us
169
+ if (!call.parent) continue
170
+ const parent = packageOf(call.parent, names)
171
+ if (parent === '(Node 内部)' || parent === '(原生/GC)') continue
172
+ moduleRow(parent).resolveUs += call.us
173
+ const target = /^node:/.test(call.specifier) ? '(Node 内部)' : packageOf(call.url, names)
174
+ if (target === parent || target === '(Node 内部)' || target === '(原生/GC)') continue
175
+ if (!moduleEdges.has(parent)) moduleEdges.set(parent, new Set())
176
+ moduleEdges.get(parent).add(target)
177
+ }
178
+ for (const call of timing.loadCalls || []) {
179
+ moduleTotalUs += call.us
180
+ const pkg = packageOf(call.url, names)
181
+ if (pkg === '(原生/GC)') continue
182
+ moduleRow(pkg).loadUs += call.us
183
+ }
184
+
185
+ // 每个包从哪些插件可达;共用依赖按引用它的插件数均摊,和空间构成的口径一致。
186
+ const moduleOwners = new Map()
187
+ for (const plugin of names) {
188
+ const seen = new Set([plugin])
189
+ const queue = [plugin]
190
+ while (queue.length) {
191
+ const pkg = queue.shift()
192
+ for (const next of moduleEdges.get(pkg) || []) {
193
+ if (seen.has(next)) continue
194
+ seen.add(next)
195
+ queue.push(next)
196
+ }
197
+ }
198
+ for (const pkg of seen) {
199
+ if (!moduleOwners.has(pkg)) moduleOwners.set(pkg, new Set())
200
+ moduleOwners.get(pkg).add(plugin)
201
+ }
202
+ }
203
+
204
+ const plugins = [...names].map((name) => {
205
+ let loadUs = 0
206
+ let runUs = 0
207
+ for (const [pkg, owners] of moduleOwners) {
208
+ if (!owners.has(name)) continue
209
+ const share = 1 / owners.size
210
+ const module = moduleUs.get(pkg)
211
+ if (module) loadUs += (module.resolveUs + module.loadUs) * share
212
+ runUs += ((cpuUs.get(pkg) || 0) + (nativeByPackage.get(pkg) || 0)) * share
213
+ }
214
+ const loadMs = Math.round(loadUs / 1000)
215
+ const runMs = Math.round(runUs / 1000)
216
+ return { name, loadMs, runMs, totalMs: loadMs + runMs }
217
+ }).sort((left, right) => right.totalMs - left.totalMs || right.runMs - left.runMs)
218
+
219
+ const packages = [...new Set([...cpuUs.keys(), ...nativeByPackage.keys(), ...allocBytes.keys(), ...moduleUs.keys()])].map((name) => {
220
+ const cpuMs = Math.round((cpuUs.get(name) || 0) / 1000)
221
+ const nativeMs = Math.round((nativeByPackage.get(name) || 0) / 1000)
222
+ const module = moduleUs.get(name)
223
+ const loadMs = Math.round((module?.loadUs || 0) / 1000)
224
+ const resolveMs = Math.round((module?.resolveUs || 0) / 1000)
225
+ return {
226
+ name,
227
+ cpuMs,
228
+ nativeMs,
229
+ loadMs,
230
+ resolveMs,
231
+ totalMs: cpuMs + nativeMs + loadMs + resolveMs,
232
+ allocKB: Math.round((allocBytes.get(name) || 0) / 1024),
233
+ plugin: names.has(name),
234
+ }
235
+ }).sort((a, b) => b.totalMs - a.totalMs || b.cpuMs - a.cpuMs)
102
236
 
103
237
  return {
104
238
  version: REPORT_VERSION,
@@ -109,6 +243,9 @@ function summarize(cpuProfile, heapProfile) {
109
243
  idleMs: Math.round(idleUs / 1000),
110
244
  allocMB: +(totalAlloc / 1048576).toFixed(1),
111
245
  packages,
246
+ plugins,
247
+ moduleLoadMs: Math.round(moduleTotalUs / 1000),
248
+ nativeUnattributedMs: Math.round(nativeUnattributedUs / 1000),
112
249
  native: [...nativeUs.entries()]
113
250
  .map(([name, us]) => ({ name, cpuMs: Math.round(us / 1000) }))
114
251
  .sort((a, b) => b.cpuMs - a.cpuMs)
@@ -132,11 +269,12 @@ async function stop() {
132
269
  stopped = true
133
270
  if (timer !== null) { clearTimeout(timer); timer = null }
134
271
  if (cap !== null) { clearTimeout(cap); cap = null }
272
+ stopModuleTiming()
135
273
  if (!session) return
136
274
  try {
137
275
  const cpu = await session.post('Profiler.stop')
138
276
  // 堆分配采样已撤:账本只看 CPU;内存用启动结算时读一次 RSS。
139
- writeReport(summarize(cpu.profile, null))
277
+ writeReport(summarize(cpu.profile, null, { resolveCalls, loadCalls }))
140
278
  } catch { /* 采样失败不能影响运行 */ }
141
279
  try { session.disconnect() } catch { /* ignore */ }
142
280
  }
@@ -180,6 +318,8 @@ async function start() {
180
318
 
181
319
  export function installPluginProfile() {
182
320
  if (!DIR) return
321
+ // 模块加载计时要抢在 dsh 的入口模块之前装上(--import 阶段)。
322
+ installModuleTiming()
183
323
  globalThis[KEY] = {
184
324
  enter(entry) {
185
325
  if (!entry || pending.has(entry)) return
package/public/index.html CHANGED
@@ -895,7 +895,7 @@
895
895
  </form>
896
896
  <p class="hint" id="versionManageHint" role="status" aria-live="polite"></p>
897
897
  <div class="progress" id="versionManageProgress" hidden><div class="progress-bar" id="versionManageProgressBar"></div></div>
898
- <h3 class="version-manage-heading" data-i18n="已安装">已安装</h3>
898
+ <div class="version-manage-heading-row"><h3 class="version-manage-heading" data-i18n="已安装">已安装</h3><span class="version-manage-total" id="versionManageTotal"></span></div>
899
899
  <div class="version-manage-list" id="versionManageList" role="list"></div>
900
900
  </div>
901
901
  </div>
@@ -904,16 +904,26 @@
904
904
  <div class="dialog-heading"><div><h2 class="ask-title" id="launchLogTitle" data-i18n="启动项日志">启动项日志</h2><p class="dialog-subtitle" id="launchLogSubtitle"></p></div><button class="ghost icon-btn dialog-close" id="launchLogClose" type="button" title="关闭" aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg></button></div>
905
905
  <div class="dialog-body">
906
906
  <div class="launch-log-tools" id="launchLogTools" hidden><span class="hint" id="launchLogHint"></span></div>
907
+ <div class="launch-log-tabs" id="launchLogTabs" role="tablist" aria-label="启动项日志视图" data-i18n-aria-label="启动项日志视图">
908
+ <button class="launch-log-tab" type="button" role="tab" data-log-tab="raw" aria-selected="true" data-i18n="原始日志">原始日志</button>
909
+ <button class="launch-log-tab" type="button" role="tab" data-log-tab="time" aria-selected="false" data-i18n="耗时构成" hidden>耗时构成</button>
910
+ <button class="launch-log-tab" type="button" role="tab" data-log-tab="space" aria-selected="false" data-i18n="空间构成" hidden>空间构成</button>
911
+ <button class="launch-log-tab" type="button" role="tab" data-log-tab="diagnosis" aria-selected="false" data-i18n="插件诊断" hidden>插件诊断</button>
912
+ </div>
907
913
  <div class="launch-log-loading" id="launchLogLoading" hidden><span class="spin"></span><span data-i18n="正在读取日志…">正在读取日志…</span></div>
908
- <section class="launch-log-card" id="launchLogDiagnosis" hidden>
914
+ <section class="launch-log-card" id="launchLogDiagnosis" data-log-panel="diagnosis" hidden>
909
915
  <div class="launch-log-card-head"><h3 data-i18n="插件诊断">插件诊断</h3><span class="launch-log-head-meta"><span id="launchLogDiagnosisMeta"></span></span></div>
910
916
  <div class="launch-log-block" id="launchLogDiagnosisRows"></div>
911
917
  </section>
912
- <section class="launch-log-card" id="launchLogVisual" hidden>
918
+ <section class="launch-log-card" id="launchLogVisual" data-log-panel="time" hidden>
913
919
  <div class="launch-log-card-head"><h3 data-i18n="耗时构成">耗时构成</h3><span class="launch-log-head-meta"><span id="launchLogWindow"></span><span id="launchLogRss" hidden></span></span></div>
914
920
  <div class="launch-log-block" id="launchLogPlugins"></div>
915
921
  </section>
916
- <section class="launch-log-card" id="launchLogCard">
922
+ <section class="launch-log-card" id="launchLogSpace" data-log-panel="space" hidden>
923
+ <div class="launch-log-card-head"><h3 data-i18n="空间构成">空间构成</h3><span class="launch-log-head-meta"><span id="launchLogSpaceMeta"></span></span></div>
924
+ <div class="launch-log-block" id="launchLogSpaceBody"></div>
925
+ </section>
926
+ <section class="launch-log-card" id="launchLogCard" data-log-panel="raw">
917
927
  <div class="launch-log-card-head"><h3 data-i18n="原始日志">原始日志</h3><span class="launch-log-actions"><button class="ghost" type="button" id="launchLogCopy" data-i18n="复制">复制</button><button class="ghost" type="button" id="launchLogExport" data-i18n="导出">导出</button><button class="ghost" type="button" id="launchLogClear" data-i18n="清空">清空</button></span></div>
918
928
  <pre class="launch-log-text" id="launchLogText"></pre>
919
929
  </section>
@@ -1191,6 +1201,8 @@
1191
1201
  "这个 profile 已存在,依赖与层栈会并进去": "this profile exists — dependencies and layers are merged in",
1192
1202
  "安装": "Install",
1193
1203
  "已安装": "Installed",
1204
+ "共 {size}": "{size} total",
1205
+ "正在计算占用…": "Calculating disk usage…",
1194
1206
  "不看这个了": "Dismiss",
1195
1207
  "正在读社区整合包列表…": "Reading the community modpack list…",
1196
1208
  "市场里现在有 {n} 个整合包。": "The market has {n} modpack(s) right now.",
@@ -1530,6 +1542,7 @@
1530
1542
  "启动耗时 {seconds}s": "Started in {seconds}s",
1531
1543
  "启动明细": "Startup details",
1532
1544
  "启动项日志": "Launch log",
1545
+ "启动项日志视图": "Launch log view",
1533
1546
  "查看 {name} 的日志": "View the log for {name}",
1534
1547
  "正在读取日志…": "Reading log…",
1535
1548
  "这个启动项还没有日志。启动一次后,它自己的输出会出现在这里。": "This launch entry has no log yet. Start it once and its own output will show up here.",
@@ -1557,8 +1570,31 @@
1557
1570
  "主线程 {pct}% 在跑;插件合计只占 {plugin}%,其余主要是模块解析与文件扫描。": "Main thread busy {pct}%; plugins only {plugin}%, the rest is module resolution and file scanning.",
1558
1571
  "原始日志": "Raw log",
1559
1572
  "耗时构成": "Time breakdown",
1573
+ "空间构成": "Disk usage",
1574
+ "运行时": "Runtime",
1575
+ "插件本体": "Plugin packages",
1576
+ "插件依赖": "Plugin dependencies",
1577
+ "运行时依赖": "Runtime dependencies",
1578
+ "配置与数据": "Config & data",
1579
+ "未归类": "Unaccounted",
1580
+ "合计 {size}": "{size} total",
1581
+ "最占空间的 5 项": "Top 5 by size",
1582
+ "各插件占用(含它带进来的依赖)": "Disk usage by plugin (including its dependencies)",
1583
+ "其余 {n} 个插件合计 {size}": "Other {n} plugins: {size} total",
1584
+ "插件按它引用到的依赖计入(共用依赖按引用它的插件均摊);合计是版本目录 + 这个环境目录的逻辑大小,pnpm 共享 store 不重复计入。": "Plugins are counted with the dependencies they pull in (shared dependencies are split across the plugins that use them); the total is the logical size of the version directory plus this environment directory, and the shared pnpm store is not counted twice.",
1560
1585
  "窗口 {window} · CPU {cpu}": "Window {window} · CPU {cpu}",
1561
1586
  "最耗时的 5 项": "Top 5 by time",
1587
+ "最耗时的 5 项(不含 Node 内部)": "Top 5 by time (excluding Node internals)",
1588
+ "各插件耗时(含依赖加载与运行)": "Time by plugin (including dependency loading and execution)",
1589
+ "插件(加载+运行)": "Plugins (load + run)",
1590
+ "运行时与其它": "Runtime & other",
1591
+ "加载 {load} · 运行 {run}": "load {load} · run {run}",
1592
+ "加载 {load} · 解析 {resolve} · JS {cpu} · 原生 {native}": "load {load} · resolve {resolve} · JS {cpu} · native {native}",
1593
+ "原生与 GC 明细": "Native & GC breakdown",
1594
+ "原生/GC 已按最近的 JS 调用方归到包;这里只按系统调用/GC 类型汇总。": "Native/GC time is attributed to the nearest JS caller's package; this lists it by syscall/GC type only.",
1595
+ "插件合计 {plugin}%(含它带进来的依赖);其余为运行时启动、文件扫描与未归属。": "Plugins total {plugin}% (including the dependencies they pull in); the rest is runtime startup, file scanning and unattributed time.",
1596
+ "未归属原生/GC": "Unattributed native/GC",
1597
+ "JS {cpu} · 原生 {native}": "JS {cpu} · native {native}",
1562
1598
  "插件合计只占 {plugin}%;其余为模块解析、文件扫描与 Node 启动。": "Plugins account for only {plugin}%; the rest is module resolution, file scanning and Node startup.",
1563
1599
  "清空这个启动项的日志?": "Clear this launch entry's log?",
1564
1600
  "日志已清空。": "Log cleared.",
@@ -2179,8 +2215,19 @@
2179
2215
  const launchLogCardEl = document.getElementById('launchLogCard')
2180
2216
  const launchLogVisualEl = document.getElementById('launchLogVisual')
2181
2217
  const launchLogPluginsEl = document.getElementById('launchLogPlugins')
2218
+ const launchLogSpaceEl = document.getElementById('launchLogSpace')
2219
+ const launchLogSpaceMetaEl = document.getElementById('launchLogSpaceMeta')
2220
+ const launchLogSpaceBodyEl = document.getElementById('launchLogSpaceBody')
2182
2221
  const launchLogDiagnosisEl = document.getElementById('launchLogDiagnosis')
2183
2222
  const launchLogDiagnosisMetaEl = document.getElementById('launchLogDiagnosisMeta')
2223
+ const launchLogTabsEl = document.getElementById('launchLogTabs')
2224
+ const launchLogTabButtons = [...document.querySelectorAll('.launch-log-tab[data-log-tab]')]
2225
+ const launchLogPanels = {
2226
+ raw: launchLogCardEl,
2227
+ time: launchLogVisualEl,
2228
+ space: launchLogSpaceEl,
2229
+ diagnosis: launchLogDiagnosisEl,
2230
+ }
2184
2231
  const launchLogDiagnosisRowsEl = document.getElementById('launchLogDiagnosisRows')
2185
2232
  const manageVersionsEl = document.getElementById('launchManageVersions')
2186
2233
  const versionInstallSelectEl = document.getElementById('versionInstallSelect')
@@ -5126,6 +5173,10 @@
5126
5173
  let launchLogTimer = null
5127
5174
  let launchLogQuery = 0
5128
5175
  let launchLogReportKey = ''
5176
+ let launchLogStorageKey = ''
5177
+ // 日志弹窗的页签:原始日志常驻;耗时/空间/诊断有数据才出现
5178
+ let launchLogTab = 'raw'
5179
+ const launchLogAvailable = { raw: true, time: false, space: false, diagnosis: false }
5129
5180
  // 耗时账本按「这一次运行」缓存:同一次运行反复打开直接复用,重启后 runAt 变了才重取。
5130
5181
  const launchReportCache = new Map()
5131
5182
  // 日志正文按启动项缓存:再次打开先显示上次看到的全文,后台刷新再替换,不用空白等一次请求。
@@ -5141,8 +5192,24 @@
5141
5192
  return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`
5142
5193
  }
5143
5194
 
5195
+ /** 只显示当前页签对应的卡片;没有内容的页签直接收起来。 */
5196
+ function paintLaunchLogTabs() {
5197
+ if (!launchLogAvailable[launchLogTab]) launchLogTab = 'raw'
5198
+ for (const button of launchLogTabButtons) {
5199
+ const key = button.dataset.logTab
5200
+ button.hidden = !launchLogAvailable[key]
5201
+ button.setAttribute('aria-selected', String(key === launchLogTab))
5202
+ button.tabIndex = key === launchLogTab ? 0 : -1
5203
+ }
5204
+ for (const [key, panel] of Object.entries(launchLogPanels)) {
5205
+ panel.hidden = !(launchLogAvailable[key] && key === launchLogTab)
5206
+ }
5207
+ launchLogTabsEl.hidden = false
5208
+ }
5209
+
5144
5210
  function syncLaunchLogVisual() {
5145
- launchLogVisualEl.hidden = launchLogPluginsEl.hidden
5211
+ launchLogAvailable.time = !launchLogPluginsEl.hidden && launchLogPluginsEl.innerHTML.trim().length > 0
5212
+ paintLaunchLogTabs()
5146
5213
  }
5147
5214
 
5148
5215
  /** 诊断里的问题行:同一个包只列一次,标签沿用诊断面板的叫法。 */
@@ -5189,6 +5256,8 @@
5189
5256
  function renderLaunchLogDiagnosis(warning) {
5190
5257
  const rows = warning ? diagnosisIssueRows(warning.diagnosis) : []
5191
5258
  launchLogDiagnosisEl.hidden = !rows.length
5259
+ launchLogAvailable.diagnosis = rows.length > 0
5260
+ paintLaunchLogTabs()
5192
5261
  launchLogDiagnosisMetaEl.textContent = rows.length ? [warning.profile, warning.version].filter(Boolean).join(' · ') : ''
5193
5262
  launchLogDiagnosisRowsEl.replaceChildren(...rows.map((row) => {
5194
5263
  const line = document.createElement('div')
@@ -5205,50 +5274,79 @@
5205
5274
  }))
5206
5275
  }
5207
5276
 
5208
- /** 启动 CPU 账本:插件 / Node 内部 / 其它包 / 原生与 GC 四桶相加,别再让时间“不知去向”。 */
5277
+ /** 启动 CPU 账本:插件按「依赖加载 + 运行」归并,剩余是运行时与未归属原生/GC。 */
5209
5278
  function renderLaunchReport(report) {
5210
5279
  const packages = Array.isArray(report?.packages) ? report.packages : []
5211
5280
  const native = Array.isArray(report?.native) ? report.native : []
5212
- const sumOf = (rows, pick) => rows.reduce((sum, row) => sum + (pick(row) ? Number(row.cpuMs) || 0 : 0), 0)
5213
- const pluginMs = sumOf(packages, (row) => row.plugin)
5214
- const nodeMs = sumOf(packages, (row) => row.name === '(Node 内部)')
5215
- const otherMs = sumOf(packages, (row) => !row.plugin && row.name !== '(Node 内部)')
5216
- const nativeMs = sumOf(native, () => true)
5217
- const ledger = pluginMs + nodeMs + otherMs + nativeMs
5218
- if (ledger < 1) {
5281
+ const totalOf = (row) => Number(row.totalMs ?? row.cpuMs) || 0
5282
+ const sumOf = (rows, pick) => rows.reduce((sum, row) => sum + (pick(row) ? totalOf(row) : 0), 0)
5283
+ // 新报告带 plugins:加载耗时已经沿依赖图归并,还带运行耗时;旧报告退回按包算。
5284
+ const pluginList = Array.isArray(report?.plugins)
5285
+ ? report.plugins.map((row) => ({
5286
+ name: String(row.name || ''),
5287
+ loadMs: Number(row.loadMs) || 0,
5288
+ runMs: Number(row.runMs) || 0,
5289
+ totalMs: Number(row.totalMs) || (Number(row.loadMs) || 0) + (Number(row.runMs) || 0),
5290
+ })).filter((row) => row.name && row.totalMs >= 1).sort((left, right) => right.totalMs - left.totalMs)
5291
+ : null
5292
+ const pluginMs = pluginList ? pluginList.reduce((sum, row) => sum + row.totalMs, 0) : sumOf(packages, (row) => row.plugin)
5293
+ // 新报告把原生/GC 按调用方归到包,只把找不到调用方的部分单列;旧报告没有归属,全部单列。
5294
+ const unattributed = Number(report?.nativeUnattributedMs)
5295
+ const nativeMs = Number.isFinite(unattributed) ? unattributed : native.reduce((sum, row) => sum + (Number(row.cpuMs) || 0), 0)
5296
+ const cpu = Math.max(1, Number(report.cpuMs) || 0)
5297
+ const runtimeMs = Math.max(0, cpu - pluginMs - nativeMs)
5298
+ const ledger = pluginMs + runtimeMs + nativeMs
5299
+ // 没有采样(连包和原生调用都没有)时没有账本可画,别只显示一条 100% 的运行时。
5300
+ if (ledger < 1 || (!packages.length && !native.length && pluginMs < 1)) {
5219
5301
  launchLogPluginsEl.hidden = true
5220
5302
  launchLogPluginsEl.innerHTML = ''
5221
5303
  syncLaunchLogVisual()
5222
5304
  return
5223
5305
  }
5224
- const cpu = Math.max(1, Number(report.cpuMs) || ledger)
5225
- const percent = (ms) => Math.round(ms / cpu * 100)
5306
+ const total = Math.max(1, ledger)
5307
+ const percent = (ms) => Math.round(ms / total * 100)
5226
5308
  const segments = [
5227
- { key: 'plugin', label: t('插件'), ms: pluginMs },
5228
- { key: 'node', label: t('Node 内部'), ms: nodeMs },
5229
- { key: 'other', label: t('其它包'), ms: otherMs },
5230
- { key: 'native', label: t('原生与 GC'), ms: nativeMs },
5231
- { key: 'rest', label: t('未归类'), ms: Math.max(0, cpu - ledger) },
5309
+ { key: 'plugin', label: t('插件(加载+运行)'), ms: pluginMs },
5310
+ { key: 'node', label: t('运行时与其它'), ms: runtimeMs },
5311
+ { key: 'native', label: t('未归属原生/GC'), ms: nativeMs },
5232
5312
  ].filter((segment) => segment.ms >= 1)
5233
- const rows = [...packages].filter((row) => Number(row.cpuMs) >= 1).sort((left, right) => right.cpuMs - left.cpuMs).slice(0, 5)
5234
- const max = Math.max(...rows.map((row) => Number(row.cpuMs) || 0), 1)
5235
- const busy = Number(report.windowMs) > 0 && Number(report.cpuMs) / Number(report.windowMs) >= 0.8
5236
- const nativeList = native
5237
- .filter((row) => Number(row.cpuMs) >= 1)
5238
- .sort((left, right) => right.cpuMs - left.cpuMs)
5239
- .slice(0, 3)
5240
- .map((row) => `${row.name} ${formatProfileMs(row.cpuMs)}`)
5241
- .join(' · ')
5313
+ const splitTitle = (row) => t('JS {cpu} · 原生 {native}', { cpu: formatProfileMs(Number(row.cpuMs) || 0), native: formatProfileMs(Number(row.nativeMs) || 0) })
5314
+ const packageTitle = (row) => row.resolveMs === undefined
5315
+ ? splitTitle(row)
5316
+ : t('加载 {load} · 解析 {resolve} · JS {cpu} · 原生 {native}', {
5317
+ load: formatProfileMs(Number(row.loadMs) || 0),
5318
+ resolve: formatProfileMs(Number(row.resolveMs) || 0),
5319
+ cpu: formatProfileMs(Number(row.cpuMs) || 0),
5320
+ native: formatProfileMs(Number(row.nativeMs) || 0),
5321
+ })
5322
+ const pluginTitle = (row) => pluginList
5323
+ ? t('加载 {load} · 运行 {run}', { load: formatProfileMs(row.loadMs), run: formatProfileMs(row.runMs) })
5324
+ : splitTitle(row)
5325
+ const pluginValue = (row) => pluginList ? row.totalMs : totalOf(row)
5326
+ const pluginRows = pluginList
5327
+ ? pluginList.slice(0, 5)
5328
+ : packages.filter((row) => row.plugin && totalOf(row) >= 1).sort((left, right) => totalOf(right) - totalOf(left)).slice(0, 5)
5329
+ const pluginMax = Math.max(...pluginRows.map(pluginValue), 1)
5330
+ // Node 内部的采样大多是模块加载/文件扫描,已经体现在插件的加载耗时和运行时里,单列会重复加总。
5331
+ const rows = packages
5332
+ .filter((row) => row.name !== '(Node 内部)' && row.name !== '(启动器钩子)' && totalOf(row) >= 1)
5333
+ .sort((left, right) => totalOf(right) - totalOf(left))
5334
+ .slice(0, 5)
5335
+ const max = Math.max(...rows.map((row) => totalOf(row)), 1)
5336
+ const nativeRows = native.filter((row) => Number(row.cpuMs) >= 1).sort((left, right) => right.cpuMs - left.cpuMs).slice(0, 6)
5337
+ const nativeMax = Math.max(...nativeRows.map((row) => Number(row.cpuMs) || 0), 1)
5242
5338
  launchLogWindowEl.textContent = t('窗口 {window} · CPU {cpu}', { window: formatProfileMs(report.windowMs), cpu: formatProfileMs(report.cpuMs) })
5243
5339
  launchLogPluginsEl.innerHTML = `
5244
- <div class="launch-log-ledger" role="img" aria-label="${escapeHtml(segments.map((segment) => `${segment.label} ${formatProfileMs(segment.ms)}`).join(','))}">${segments.map((segment) => `<span class="ledger-${segment.key}" style="flex:${Math.max(segment.ms, cpu * 0.004)} 1 0" title="${escapeHtml(`${segment.label} ${formatProfileMs(segment.ms)}`)}"><span>${percent(segment.ms)}%</span></span>`).join('')}</div>
5340
+ <div class="launch-log-ledger" role="img" aria-label="${escapeHtml(segments.map((segment) => `${segment.label} ${formatProfileMs(segment.ms)}`).join(','))}">${segments.map((segment) => `<span class="ledger-${segment.key}" style="flex:${Math.max(segment.ms, total * 0.004)} 1 0" title="${escapeHtml(`${segment.label} ${formatProfileMs(segment.ms)}`)}"><span>${percent(segment.ms)}%</span></span>`).join('')}</div>
5245
5341
  <div class="launch-log-legend">${segments.map((segment) => `<span><i class="ledger-${segment.key}"></i>${escapeHtml(segment.label)} <b>${escapeHtml(formatProfileMs(segment.ms))}</b></span>`).join('')}</div>
5246
- <div class="launch-log-subhead">${escapeHtml(t('最耗时的 5 项'))}</div>
5247
- <div class="launch-log-bars">${rows.map((row) => `<div class="launch-log-bar-row${row.plugin ? ' is-plugin' : ''}"><span class="name" title="${escapeHtml(row.name)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(row.cpuMs) || 0) / max * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(row.cpuMs))}</span></div>`).join('')}</div>
5248
- ${nativeList ? `<p class="hint launch-log-note">${escapeHtml(t('原生与 GC {total}:{list}', { total: formatProfileMs(nativeMs), list: nativeList }))}</p>` : ''}
5249
- <p class="hint launch-log-note">${escapeHtml(busy
5250
- ? t('插件合计只占 {plugin}%;其余为模块解析、文件扫描与 Node 启动。', { plugin: percent(pluginMs) })
5251
- : t('插件合计只占 {plugin}%。', { plugin: percent(pluginMs) }))}</p>`
5342
+ ${pluginRows.length ? `<div class="launch-log-subhead">${escapeHtml(t('各插件耗时(含依赖加载与运行)'))}</div>
5343
+ <div class="launch-log-bars">${pluginRows.map((row) => `<div class="launch-log-bar-row is-plugin"><span class="name" title="${escapeHtml(`${row.name} · ${pluginTitle(row)}`)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, pluginValue(row) / pluginMax * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(pluginValue(row)))}</span></div>`).join('')}</div>` : ''}
5344
+ <div class="launch-log-subhead">${escapeHtml(t('最耗时的 5 项(不含 Node 内部)'))}</div>
5345
+ <div class="launch-log-bars">${rows.map((row) => `<div class="launch-log-bar-row${row.plugin ? ' is-plugin' : ''}"><span class="name" title="${escapeHtml(`${row.name} · ${packageTitle(row)}`)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, totalOf(row) / max * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(totalOf(row)))}</span></div>`).join('')}</div>
5346
+ ${nativeRows.length ? `<div class="launch-log-subhead">${escapeHtml(t('原生与 GC 明细'))}</div>
5347
+ <div class="launch-log-bars">${nativeRows.map((row) => `<div class="launch-log-bar-row"><span class="name" title="${escapeHtml(row.name)}">${escapeHtml(row.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(row.cpuMs) || 0) / nativeMax * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatProfileMs(row.cpuMs))}</span></div>`).join('')}</div>` : ''}
5348
+ ${nativeRows.length ? `<p class="hint launch-log-note">${escapeHtml(t('原生/GC 已按最近的 JS 调用方归到包;这里只按系统调用/GC 类型汇总。'))}</p>` : ''}
5349
+ <p class="hint launch-log-note">${escapeHtml(t('插件合计 {plugin}%(含它带进来的依赖);其余为运行时启动、文件扫描与未归属。', { plugin: percent(pluginMs) }))}</p>`
5252
5350
  launchLogPluginsEl.hidden = false
5253
5351
  syncLaunchLogVisual()
5254
5352
  }
@@ -5279,6 +5377,63 @@
5279
5377
  }
5280
5378
  }
5281
5379
 
5380
+ /** 启动项的空间构成:版本目录 + 这个 profile 目录,按插件 / 运行时 / 其它依赖 / 配置与数据分。 */
5381
+ function renderStorageBreakdown(data) {
5382
+ const labels = {
5383
+ plugin: t('插件本体'), 'plugin-deps': t('插件依赖'), runtime: t('运行时'),
5384
+ 'runtime-deps': t('运行时依赖'), data: t('配置与数据'), rest: t('未归类'),
5385
+ }
5386
+ const segments = (data?.segments || [])
5387
+ .map((segment) => ({ ...segment, label: labels[segment.key] || segment.key }))
5388
+ .filter((segment) => Number(segment.bytes) >= 1)
5389
+ const total = Math.max(1, Number(data?.total) || 0)
5390
+ const percent = (bytes) => Math.round(Number(bytes) / total * 100)
5391
+ const items = (data?.items || []).filter((item) => Number(item.bytes) >= 1)
5392
+ const max = Math.max(...items.map((item) => Number(item.bytes) || 0), 1)
5393
+ const plugins = (data?.plugins || []).filter((plugin) => Number(plugin.bytes) >= 1)
5394
+ const pluginMax = Math.max(...plugins.map((plugin) => Number(plugin.bytes) || 0), 1)
5395
+ const restPlugins = plugins.slice(5)
5396
+ launchLogSpaceMetaEl.textContent = t('合计 {size}', { size: formatBytes(data.total) })
5397
+ launchLogSpaceBodyEl.innerHTML = `
5398
+ <div class="launch-log-ledger" role="img" aria-label="${escapeHtml(segments.map((segment) => `${segment.label} ${formatBytes(segment.bytes)}`).join(','))}">${segments.map((segment) => `<span class="ledger-${segment.key}" style="flex:${Math.max(segment.bytes, total * 0.004)} 1 0" title="${escapeHtml(`${segment.label} ${formatBytes(segment.bytes)}`)}"><span>${percent(segment.bytes)}%</span></span>`).join('')}</div>
5399
+ <div class="launch-log-legend">${segments.map((segment) => `<span><i class="ledger-${segment.key}"></i>${escapeHtml(segment.label)} <b>${escapeHtml(formatBytes(segment.bytes))}</b></span>`).join('')}</div>
5400
+ ${plugins.length ? `<div class="launch-log-subhead">${escapeHtml(t('各插件占用(含它带进来的依赖)'))}</div>
5401
+ <div class="launch-log-bars">${plugins.slice(0, 5).map((plugin) => `<div class="launch-log-bar-row is-plugin"><span class="name" title="${escapeHtml(plugin.name)}">${escapeHtml(plugin.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(plugin.bytes) || 0) / pluginMax * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatBytes(plugin.bytes))}</span></div>`).join('')}</div>
5402
+ ${restPlugins.length ? `<p class="hint launch-log-note">${escapeHtml(t('其余 {n} 个插件合计 {size}', { n: restPlugins.length, size: formatBytes(restPlugins.reduce((sum, plugin) => sum + Number(plugin.bytes || 0), 0)) }))}</p>` : ''}` : ''}
5403
+ <div class="launch-log-subhead">${escapeHtml(t('最占空间的 5 项'))}</div>
5404
+ <div class="launch-log-bars">${items.map((item) => `<div class="launch-log-bar-row${item.plugin ? ' is-plugin' : ''}"><span class="name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span><span class="track"><span class="fill" style="width:${Math.max(2, (Number(item.bytes) || 0) / max * 100).toFixed(1)}%"></span></span><span class="value">${escapeHtml(formatBytes(item.bytes))}</span></div>`).join('')}</div>
5405
+ <p class="hint launch-log-note">${escapeHtml(t('插件按它引用到的依赖计入(共用依赖按引用它的插件均摊);合计是版本目录 + 这个环境目录的逻辑大小,pnpm 共享 store 不重复计入。'))}</p>`
5406
+ launchLogAvailable.space = true
5407
+ paintLaunchLogTabs()
5408
+ }
5409
+
5410
+ async function loadStorageBreakdown(entry, version) {
5411
+ if (!entry || !version) return
5412
+ const profile = entry.profile || 'web'
5413
+ const key = `${entry.id}\0${version}\0${profile}`
5414
+ launchLogStorageKey = key
5415
+ launchLogSpaceEl.hidden = false
5416
+ launchLogSpaceMetaEl.textContent = t('正在计算占用…')
5417
+ launchLogSpaceBodyEl.innerHTML = ''
5418
+ try {
5419
+ const data = await getJson(
5420
+ `/api/storage/breakdown?version=${encodeURIComponent(version)}&profile=${encodeURIComponent(profile)}`,
5421
+ { launchId: entry.id },
5422
+ )
5423
+ if (launchLogStorageKey !== key) return
5424
+ if (!Array.isArray(data?.segments) || !data.segments.length) throw new Error('empty')
5425
+ renderStorageBreakdown(data)
5426
+ } catch {
5427
+ if (launchLogStorageKey !== key) return
5428
+ // 算不出来就收起这块,不占着位置
5429
+ launchLogSpaceEl.hidden = true
5430
+ launchLogSpaceMetaEl.textContent = ''
5431
+ launchLogSpaceBodyEl.innerHTML = ''
5432
+ launchLogAvailable.space = false
5433
+ paintLaunchLogTabs()
5434
+ }
5435
+ }
5436
+
5282
5437
  /** 提示行只在有话说时出现,避免顶部留一条空白。 */
5283
5438
  function setLaunchLogHint(text) {
5284
5439
  launchLogHintEl.textContent = text || ''
@@ -5322,7 +5477,6 @@
5322
5477
  launchLogTextEl.textContent = simplifyLogText(data.text)
5323
5478
  launchLogTextCache.set(entry.id, launchLogTextEl.textContent)
5324
5479
  if (launchLogTextCache.size > 8) launchLogTextCache.delete(launchLogTextCache.keys().next().value)
5325
- launchLogCardEl.hidden = !data.text
5326
5480
  setLaunchLogHint(data.text
5327
5481
  ? (data.clipped ? t('只显示最后 120 KB。') : '')
5328
5482
  : t('这个启动项还没有日志。启动一次后,它自己的输出会出现在这里。'))
@@ -5359,16 +5513,24 @@
5359
5513
  launchLogRssEl.textContent = ''
5360
5514
  launchLogRssEl.title = ''
5361
5515
  launchLogWindowEl.textContent = ''
5362
- launchLogCardEl.hidden = !cachedText
5363
5516
  launchLogReportKey = ''
5364
5517
  launchLogPluginsEl.hidden = true
5365
5518
  launchLogPluginsEl.innerHTML = ''
5519
+ launchLogStorageKey = ''
5520
+ launchLogSpaceEl.hidden = true
5521
+ launchLogSpaceMetaEl.textContent = ''
5522
+ launchLogSpaceBodyEl.innerHTML = ''
5523
+ launchLogTab = 'raw'
5524
+ launchLogAvailable.time = false
5525
+ launchLogAvailable.space = false
5526
+ launchLogAvailable.diagnosis = false
5366
5527
  renderLaunchLogDiagnosis(null)
5367
5528
  syncLaunchLogVisual()
5368
5529
  await showDialog(launchLogDialogEl)
5369
5530
  launchLogTextEl.scrollTop = launchLogTextEl.scrollHeight
5370
5531
  const data = await refreshLaunchLog()
5371
5532
  if (version) await loadLaunchReport(version, entry.profile || 'web', data?.runs?.[0]?.at || '')
5533
+ if (version) void loadStorageBreakdown(entry, version)
5372
5534
  launchLogLoadingEl.hidden = true
5373
5535
  clearInterval(launchLogTimer)
5374
5536
  launchLogTimer = setInterval(() => {
@@ -5378,6 +5540,7 @@
5378
5540
 
5379
5541
  function closeLaunchLog() {
5380
5542
  launchLogEntry = null
5543
+ launchLogStorageKey = ''
5381
5544
  launchLogQuery += 1
5382
5545
  launchLogLoadingEl.hidden = true
5383
5546
  clearInterval(launchLogTimer)
@@ -5717,6 +5880,10 @@
5717
5880
  let versionManageOrigin = null
5718
5881
  let versionManageRevision = 0
5719
5882
  let versionManageMarkup = ''
5883
+ // 版本目录大小:打开版本管理时按需向服务端要(服务端有缓存),不跟着状态推送反复扫盘
5884
+ let versionSizes = null
5885
+ let versionSizesTotal = 0
5886
+ let versionSizesLoading = false
5720
5887
  let versionInstallOptions = ''
5721
5888
 
5722
5889
  function openVersionManager() {
@@ -5732,6 +5899,7 @@
5732
5899
  }
5733
5900
  versionManageDialogEl.classList.toggle('is-child-dialog', versionManageReturnToEditor)
5734
5901
  paintVersionManager()
5902
+ void loadVersionSizes()
5735
5903
  showDialog(versionManageDialogEl)
5736
5904
  document.getElementById('versionManageClose').focus()
5737
5905
  // 打开就顺手静默刷新一次远程列表(服务端 60 秒内走缓存,不会每次都打网络):
@@ -5831,6 +5999,7 @@
5831
5999
  try {
5832
6000
  await post('/api/install', { version })
5833
6001
  applyState(await getJson('/api/state'))
6002
+ void loadVersionSizes()
5834
6003
  } catch (error) { notify(error.message) }
5835
6004
  finally { action = ''; render() }
5836
6005
  }
@@ -5843,13 +6012,45 @@
5843
6012
  try {
5844
6013
  await post('/api/versions/optimize', { version })
5845
6014
  applyState(await getJson('/api/state'))
6015
+ void loadVersionSizes()
5846
6016
  notify(t('依赖优化完成'), t('优化依赖'))
5847
6017
  } catch (error) { notify(error.message) }
5848
6018
  finally { action = ''; render() }
5849
6019
  }
5850
6020
 
6021
+ /** 目录大小的显示单位:B / KB / MB / GB。 */
6022
+ function formatBytes(bytes) {
6023
+ const value = Number(bytes) || 0
6024
+ if (value < 1024) return `${value} B`
6025
+ if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
6026
+ if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
6027
+ return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
6028
+ }
6029
+
6030
+ /** 打开版本管理时算一次每个版本的磁盘占用;服务端有 5 分钟缓存。 */
6031
+ async function loadVersionSizes() {
6032
+ if (typeof versionManageDialogEl === 'undefined' || versionSizesLoading) return
6033
+ versionSizesLoading = true
6034
+ if (!versionManageDialogEl.hidden) paintVersionManager()
6035
+ try {
6036
+ const data = await getJson('/api/versions/sizes')
6037
+ versionSizes = data?.versions && typeof data.versions === 'object' ? data.versions : null
6038
+ versionSizesTotal = Number(data?.total) || 0
6039
+ } catch { /* 算不出来不影响版本管理 */ }
6040
+ finally {
6041
+ versionSizesLoading = false
6042
+ if (!versionManageDialogEl.hidden) paintVersionManager()
6043
+ }
6044
+ }
6045
+
5851
6046
  function paintVersionManager() {
5852
6047
  const installed = [...installedList()].sort((a, b) => compareVersion(b, a))
6048
+ const totalEl = document.getElementById('versionManageTotal')
6049
+ if (totalEl) {
6050
+ totalEl.textContent = versionSizesLoading && !versionSizes
6051
+ ? t('正在计算占用…')
6052
+ : versionSizesTotal ? t('共 {size}', { size: formatBytes(versionSizesTotal) }) : ''
6053
+ }
5853
6054
  const latest = latestRemoteVersion()
5854
6055
  const available = [...new Set([latest, ...allVersionList()])].filter((version) => version && !installed.includes(version))
5855
6056
  .sort((a, b) => compareVersion(b, a))
@@ -5885,7 +6086,8 @@
5885
6086
  : t('正在运行 · {profile}', { profile: live.profile || t('运行中') }) : t('未运行')
5886
6087
  const label = t('卸载版本')
5887
6088
  const optimize = t('优化依赖')
5888
- return `<div class="version-manage-row" role="listitem"><div class="version-manage-info"><strong>${escapeHtml(version)}</strong>${live ? `<span title="${escapeHtml(status)}">${escapeHtml(status)}</span>` : ''}</div><button class="ghost version-optimize" type="button" data-optimize-version="${escapeHtml(version)}" title="${escapeHtml(t('整理重复依赖,减少内存和磁盘占用'))}" ${busy || live ? 'disabled' : ''}>${escapeHtml(optimize)}</button><button class="ghost icon-btn version-remove" type="button" data-remove-version="${escapeHtml(version)}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}" ${busy || live ? 'disabled' : ''}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></svg></button></div>`
6089
+ const size = versionSizes?.[version] ? formatBytes(versionSizes[version]) : ''
6090
+ return `<div class="version-manage-row" role="listitem"><div class="version-manage-info"><strong>${escapeHtml(version)}</strong>${size ? `<span class="version-manage-size">${escapeHtml(size)}</span>` : ''}${live ? `<span title="${escapeHtml(status)}">${escapeHtml(status)}</span>` : ''}</div><button class="ghost version-optimize" type="button" data-optimize-version="${escapeHtml(version)}" title="${escapeHtml(t('整理重复依赖,减少内存和磁盘占用'))}" ${busy || live ? 'disabled' : ''}>${escapeHtml(optimize)}</button><button class="ghost icon-btn version-remove" type="button" data-remove-version="${escapeHtml(version)}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}" ${busy || live ? 'disabled' : ''}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></svg></button></div>`
5889
6091
  }).join('') || `<p class="hint version-manage-empty">${escapeHtml(t('尚未安装任何版本'))}</p>`
5890
6092
  // 状态推送和安装进度频繁变化,保留相同的行以免键盘焦点被重建打断。
5891
6093
  if (versionManageMarkup !== markup) {
@@ -6050,6 +6252,7 @@
6050
6252
  try {
6051
6253
  await post('/api/uninstall', { version })
6052
6254
  applyState(await getJson('/api/state'))
6255
+ void loadVersionSizes()
6053
6256
  }
6054
6257
  catch (error) { notify(error.message) }
6055
6258
  finally { action = ''; render() }
@@ -9371,6 +9574,21 @@
9371
9574
  trapPackDialog(packExportDialog, closeExportDialog)
9372
9575
  trapPackDialog(pluginAddDialogEl, closePluginAdd)
9373
9576
  document.getElementById('launchLogClose').onclick = () => closeLaunchLog()
9577
+ for (const button of launchLogTabButtons) {
9578
+ button.onclick = () => { launchLogTab = button.dataset.logTab; paintLaunchLogTabs() }
9579
+ }
9580
+ launchLogTabsEl.onkeydown = (event) => {
9581
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
9582
+ const visible = launchLogTabButtons.filter((button) => !button.hidden)
9583
+ const index = visible.indexOf(document.activeElement)
9584
+ if (index < 0) return
9585
+ event.preventDefault()
9586
+ const step = event.key === 'ArrowRight' ? 1 : visible.length - 1
9587
+ const next = visible[(index + step) % visible.length]
9588
+ launchLogTab = next.dataset.logTab
9589
+ paintLaunchLogTabs()
9590
+ next.focus()
9591
+ }
9374
9592
  launchLogCopyEl.onclick = () => { void copyLaunchLog() }
9375
9593
  launchLogExportEl.onclick = () => { void exportLaunchLog() }
9376
9594
  launchLogClearEl.onclick = () => { void clearLaunchLog() }
@@ -1429,6 +1429,14 @@ section[data-category="advanced"] .set-group + .set-label + .set-group { margin-
1429
1429
  .launch-log-loading[hidden] { display: none; }
1430
1430
  .launch-log-loading .spin { display: block; width: 16px; height: 16px; }
1431
1431
  .launch-log-tools .hint { margin: 0; }
1432
+ /* 日志弹窗的页签:原始日志常驻,耗时/空间/诊断有数据才出现 */
1433
+ .launch-log-tabs { display: flex; gap: 2px; padding: 4px; margin-bottom: 10px; border-radius: 12px; background: var(--accent-soft); }
1434
+ .launch-log-tabs[hidden] { display: none; }
1435
+ .launch-log-tab { flex: 1 1 0; padding: 7px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; transition: color 160ms ease, background 160ms ease; }
1436
+ .launch-log-tab[hidden] { display: none; }
1437
+ .launch-log-tab:hover { color: var(--text); }
1438
+ .launch-log-tab[aria-selected="true"] { background: var(--card); color: var(--accent); font-weight: 600; box-shadow: 0 2px 8px rgba(22, 35, 61, .1); }
1439
+ .launch-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
1432
1440
  .launch-log-tools .hint:empty { display: none; }
1433
1441
  .launch-log-meta { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; background: rgba(127, 127, 127, .1); color: var(--text); font-size: 12px; }
1434
1442
  .launch-log-card { border: 1px solid var(--line); border-radius: 14px; padding: 14px; background: rgba(127, 127, 127, .035); }
@@ -1456,8 +1464,19 @@ section[data-category="advanced"] .set-group + .set-label + .set-group { margin-
1456
1464
  .ledger-other { background: #b6bcc8; }
1457
1465
  .ledger-native { background: #d99a2b; }
1458
1466
  .ledger-rest { background: #dfe3ea; }
1467
+ /* 空间构成:运行时 / 插件依赖 / 运行时依赖 / 配置与数据 */
1468
+ .ledger-runtime { background: #6f7684; }
1469
+ .ledger-plugin-deps { background: #8ea4ff; }
1470
+ .ledger-runtime-deps { background: #e0b45a; }
1471
+ .ledger-data { background: #3fa27a; }
1459
1472
  .launch-log-ledger > .ledger-rest { color: #4a5160; }
1460
1473
  .launch-log-bars { display: flex; flex-direction: column; gap: 6px; }
1474
+ /* 空间构成 / 耗时构成:标题和合计固定,内容多时在卡片内部滚动,别把弹窗撑长 */
1475
+ #launchLogSpace .launch-log-block, #launchLogVisual .launch-log-block { max-height: min(46vh, 400px); overflow-y: auto; padding-right: 8px; scrollbar-width: thin; scrollbar-color: #8ea4ff transparent; }
1476
+ #launchLogSpace .launch-log-block::-webkit-scrollbar, #launchLogVisual .launch-log-block::-webkit-scrollbar { width: 10px; }
1477
+ #launchLogSpace .launch-log-block::-webkit-scrollbar-track, #launchLogVisual .launch-log-block::-webkit-scrollbar-track { background: rgba(127, 127, 127, .08); border-radius: 999px; }
1478
+ #launchLogSpace .launch-log-block::-webkit-scrollbar-thumb, #launchLogVisual .launch-log-block::-webkit-scrollbar-thumb { background: #8ea4ff; border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
1479
+ #launchLogSpace .launch-log-block::-webkit-scrollbar-thumb:hover, #launchLogVisual .launch-log-block::-webkit-scrollbar-thumb:hover { background: #6f8cff; background-clip: content-box; }
1461
1480
  .launch-log-bar-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) 56px; gap: 10px; align-items: center; font-size: 12px; }
1462
1481
  .launch-log-bar-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
1463
1482
  .launch-log-bar-row .track { height: 7px; border-radius: 999px; background: rgba(127, 127, 127, .12); overflow: hidden; }
@@ -2407,6 +2426,10 @@ html .set-row:focus-within { background: transparent; box-shadow: none; }
2407
2426
  #versionManageHint { margin: 0; min-height: 0; font-size: 12px; }
2408
2427
  #versionManageHint:empty { display: none; }
2409
2428
  .version-manage-heading { margin: 6px 0 0; font-size: 12px; font-weight: 500; color: var(--muted); }
2429
+ /* 「已安装」右边放整体的磁盘占用;每行左边再标各自的大小 */
2430
+ .version-manage-heading-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-top: 6px; }
2431
+ .version-manage-heading-row .version-manage-heading { margin-top: 0; }
2432
+ .version-manage-total { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
2410
2433
  .version-optimize { flex: 0 0 auto; padding: 7px 10px; font-size: 12px; white-space: nowrap; }
2411
2434
  .version-manage-row .version-remove svg { width: 18px; height: 18px; }
2412
2435
  .version-manage-row .version-manage-info span { max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
package/server.js CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto'
4
4
  import { createServer } from 'node:http'
5
5
  import { createServer as createNetServer } from 'node:net'
6
6
  import { createInterface } from 'node:readline'
7
- import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
7
+ import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
8
8
  import { accessSync, chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
9
9
  import { homedir, tmpdir } from 'node:os'
10
10
  import { createRequire } from 'node:module'
@@ -199,7 +199,8 @@ let WEB_BIND = resolveWebBind()
199
199
  // 手工钉死端口的实例(键 `版本@profile` → 端口):没钉的组合每次启动由系统挑一个。
200
200
  // 和 WEB_BIND 一样是「改完立刻生效」的内存副本,启动时与保存设置时各刷新一次。
201
201
  let INSTANCE_PORTS = safeInstancePorts(loadSettingsSync().instancePorts)
202
- let ENVIRONMENT_PORTS = safeEnvironmentPorts(loadSettingsSync().environmentPorts)
202
+ // 额外目录的端口表在 defaultHome() 定义之后再填(要判断哪些目录还活着,见 #90)。
203
+ let ENVIRONMENT_PORTS = {}
203
204
  // 界面语言(zh / en):settings.json 为准;lang.txt 只为尚未选择语言的用户提供首次默认值。
204
205
  const INSTALL_LANG = join(ROOT, 'lang.txt')
205
206
  let LANG = safeLang(loadSettingsSync().lang) || installLang() || 'zh'
@@ -213,6 +214,21 @@ let HIDE_BIG_FISH = loadSettingsSync().hideBigFish === true
213
214
  let DSH_HOME_DIR = safeDshHome(loadSettingsSync().dshHome)
214
215
  const launchScope = new AsyncLocalStorage()
215
216
  const defaultHome = () => DSH_HOME_DIR || defaultDshHome()
217
+ /**
218
+ * 固定端口只认「还登记过、目录也还在」的环境。旧 DSH_HOME 被删掉/换盘后,
219
+ * environmentPorts 里那条钉子会把端口永久占住(#90),这里直接把它当不存在。
220
+ */
221
+ function liveEnvironmentPorts(stored) {
222
+ const environments = safeEnvironmentPorts(stored?.environmentPorts)
223
+ const known = [
224
+ defaultHome(),
225
+ ...safeDshHomes(stored?.dshHomes),
226
+ ...safeLaunchPresets(stored?.launchPresets).map((entry) => entry.dshHome).filter(Boolean),
227
+ ]
228
+ return Object.fromEntries(Object.entries(environments).filter(([dir]) =>
229
+ existsSync(dir) && known.some((item) => fileManager.samePath(dir, item))))
230
+ }
231
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(loadSettingsSync())
216
232
  const profileKey = (profile) => `${homeDir()}\0${profile}`
217
233
  // 启动器更新的下载源:direct(默认直连 GitHub)/ mirror(国内加速,直连失败时走前缀镜像)
218
234
  let UPDATE_SOURCE = updateSourceForDownload(loadSettingsSync().downloadSource)
@@ -525,6 +541,252 @@ function scanInstalled() {
525
541
  return found
526
542
  }
527
543
 
544
+ /** 小并发跑异步任务,避免一次 stat 几万个文件把句柄打满。 */
545
+ async function mapLimit(items, limit, task) {
546
+ let index = 0
547
+ const workers = Array.from({ length: Math.min(limit, items.length || 1) }, async () => {
548
+ while (index < items.length) {
549
+ const item = items[index]
550
+ index += 1
551
+ await task(item)
552
+ }
553
+ })
554
+ await Promise.all(workers)
555
+ }
556
+
557
+ /**
558
+ * 目录的逻辑占用。符号链接跳过:pnpm 的 node_modules 里大量是链接,顺着走会把共享 store
559
+ * 里的同一份包重复算进每个版本。
560
+ */
561
+ async function directoryBytes(dir) {
562
+ let entries
563
+ try { entries = await readdir(dir, { withFileTypes: true }) } catch { return 0 }
564
+ const files = []
565
+ const dirs = []
566
+ for (const entry of entries) {
567
+ if (entry.isSymbolicLink()) continue
568
+ if (entry.isDirectory()) dirs.push(entry.name)
569
+ else if (entry.isFile()) files.push(entry.name)
570
+ }
571
+ // 并发累加不能写 `total += await ...`:两个 worker 会读到同一个旧值,后写的把先写的覆盖掉
572
+ const sizes = []
573
+ await mapLimit(files, 32, async (name) => {
574
+ try { sizes.push((await stat(join(dir, name))).size) } catch { /* 文件刚好被删 */ }
575
+ })
576
+ await mapLimit(dirs, 8, async (name) => { sizes.push(await directoryBytes(join(dir, name))) })
577
+ return sizes.reduce((total, bytes) => total + bytes, 0)
578
+ }
579
+
580
+ const VERSION_SIZE_TTL_MS = 5 * 60_000
581
+ let versionSizesCache = { at: 0, data: null }
582
+
583
+ /** .pnpm 目录名 → 包名:`pkg@1.2.3`、`@scope+pkg@1.2.3(peer@x)`。 */
584
+ function pnpmPackageName(entry) {
585
+ const raw = String(entry).replace(/\(.*$/, '')
586
+ const at = raw.lastIndexOf('@')
587
+ return (at > 0 ? raw.slice(0, at) : raw).replace(/\+/g, '/')
588
+ }
589
+
590
+ /** 一个 node_modules 里各包的大小;pnpm 的 .pnpm 虚拟目录按包名合并,符号链接跳过。 */
591
+ async function packageSizesIn(nodeModules) {
592
+ const sizes = new Map()
593
+ // 目录名和 package.json 的 name 不一定一样(平台二进制 wrapper 常见),按真实包名归并,
594
+ // 这样依赖图(按 package.json 的 name 建)和大小才能对上。
595
+ const nameOf = (dir, fallback) => {
596
+ try { return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name || fallback } catch { return fallback }
597
+ }
598
+ const add = async (dir, fallback) => {
599
+ const name = nameOf(dir, fallback)
600
+ sizes.set(name, (sizes.get(name) || 0) + await directoryBytes(dir))
601
+ }
602
+ const entries = await readdir(nodeModules, { withFileTypes: true }).catch(() => [])
603
+ for (const entry of entries) {
604
+ if (entry.isSymbolicLink()) continue
605
+ if (entry.name === '.pnpm') {
606
+ const virtual = await readdir(join(nodeModules, '.pnpm'), { withFileTypes: true }).catch(() => [])
607
+ for (const item of virtual) {
608
+ if (item.isSymbolicLink() || !item.isDirectory()) continue
609
+ const name = pnpmPackageName(item.name)
610
+ if (!name) continue
611
+ sizes.set(name, (sizes.get(name) || 0) + await directoryBytes(join(nodeModules, '.pnpm', item.name)))
612
+ }
613
+ continue
614
+ }
615
+ if (entry.name.startsWith('.') || !entry.isDirectory()) continue
616
+ if (entry.name.startsWith('@')) {
617
+ const scoped = await readdir(join(nodeModules, entry.name), { withFileTypes: true }).catch(() => [])
618
+ for (const sub of scoped) {
619
+ if (sub.isSymbolicLink() || !sub.isDirectory()) continue
620
+ await add(join(nodeModules, entry.name, sub.name), `${entry.name}/${sub.name}`)
621
+ }
622
+ continue
623
+ }
624
+ await add(join(nodeModules, entry.name), entry.name)
625
+ }
626
+ return sizes
627
+ }
628
+
629
+ const STORAGE_BREAKDOWN_TTL_MS = 5 * 60_000
630
+ const storageBreakdownCache = new Map()
631
+
632
+ /** 包名 → 它声明的依赖名。用来把传递依赖归到引用它的插件头上。 */
633
+ async function packageGraph(nodeModules) {
634
+ const graph = new Map()
635
+ const add = async (dir) => {
636
+ try {
637
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
638
+ if (!pkg?.name) return
639
+ const deps = new Set([
640
+ ...(pkg.dependencies ? Object.keys(pkg.dependencies) : []),
641
+ ...(pkg.optionalDependencies ? Object.keys(pkg.optionalDependencies) : []),
642
+ ])
643
+ graph.set(pkg.name, deps)
644
+ } catch { /* 没有 package.json 的残缺目录 */ }
645
+ }
646
+ const entries = await readdir(nodeModules, { withFileTypes: true }).catch(() => [])
647
+ for (const entry of entries) {
648
+ if (entry.isSymbolicLink() || !entry.isDirectory() || entry.name.startsWith('.')) continue
649
+ if (entry.name.startsWith('@')) {
650
+ const scoped = await readdir(join(nodeModules, entry.name), { withFileTypes: true }).catch(() => [])
651
+ for (const sub of scoped) {
652
+ if (sub.isSymbolicLink() || !sub.isDirectory()) continue
653
+ await add(join(nodeModules, entry.name, sub.name))
654
+ }
655
+ continue
656
+ }
657
+ await add(join(nodeModules, entry.name))
658
+ }
659
+ // pnpm 隔离布局:.pnpm/<pkg>@<ver>/node_modules/<pkg>
660
+ const virtual = await readdir(join(nodeModules, '.pnpm'), { withFileTypes: true }).catch(() => [])
661
+ for (const item of virtual) {
662
+ if (item.isSymbolicLink() || !item.isDirectory()) continue
663
+ const inner = await readdir(join(nodeModules, '.pnpm', item.name, 'node_modules'), { withFileTypes: true }).catch(() => [])
664
+ for (const sub of inner) {
665
+ if (sub.isSymbolicLink() || !sub.isDirectory()) continue
666
+ if (sub.name.startsWith('@')) {
667
+ const scoped = await readdir(join(nodeModules, '.pnpm', item.name, 'node_modules', sub.name), { withFileTypes: true }).catch(() => [])
668
+ for (const child of scoped) {
669
+ if (child.isSymbolicLink() || !child.isDirectory()) continue
670
+ await add(join(nodeModules, '.pnpm', item.name, 'node_modules', sub.name, child.name))
671
+ }
672
+ continue
673
+ }
674
+ await add(join(nodeModules, '.pnpm', item.name, 'node_modules', sub.name))
675
+ }
676
+ }
677
+ return graph
678
+ }
679
+
680
+ /** 每个直接插件能到达哪些包;共用包按引用它的插件数量均摊。 */
681
+ async function attributePlugins(nodeModules, direct, sizes) {
682
+ const graph = await packageGraph(nodeModules)
683
+ const parents = new Map()
684
+ for (const start of direct) {
685
+ const seen = new Set([start])
686
+ const queue = [start]
687
+ while (queue.length) {
688
+ const current = queue.shift()
689
+ for (const next of graph.get(current) || []) {
690
+ if (seen.has(next)) continue
691
+ seen.add(next)
692
+ queue.push(next)
693
+ }
694
+ }
695
+ for (const name of seen) {
696
+ if (name === start) continue
697
+ const owners = parents.get(name) || new Set()
698
+ owners.add(start)
699
+ parents.set(name, owners)
700
+ }
701
+ }
702
+ const totals = new Map()
703
+ for (const name of direct) totals.set(name, sizes.get(name) || 0)
704
+ let depsBytes = 0
705
+ for (const [name, owners] of parents) {
706
+ if (direct.includes(name)) continue
707
+ const size = sizes.get(name) || 0
708
+ if (!size) continue
709
+ depsBytes += size
710
+ const share = size / owners.size
711
+ for (const owner of owners) totals.set(owner, (totals.get(owner) || 0) + share)
712
+ }
713
+ const plugins = [...totals.entries()]
714
+ .map(([name, bytes]) => ({ name, bytes: Math.round(bytes) }))
715
+ .filter((plugin) => plugin.bytes >= 1)
716
+ .sort((left, right) => right.bytes - left.bytes)
717
+ return { plugins, depsBytes }
718
+ }
719
+
720
+ /**
721
+ * 一个启动项(版本 + profile)的空间构成:版本目录 + 这个 profile 目录的逻辑大小。
722
+ * 插件只算 profile 的直接依赖;pnpm 共享 store 不在这些目录里,不重复计入。
723
+ */
724
+ async function storageBreakdown(version, profile) {
725
+ const key = `${homeDir()}\0${version}\0${profile}`
726
+ const cached = storageBreakdownCache.get(key)
727
+ if (cached && Date.now() - cached.at < STORAGE_BREAKDOWN_TTL_MS) return cached.data
728
+ const versionPath = versionDir(version)
729
+ const profilePath = join(homeDir(), 'profiles', safeProfile(profile))
730
+ const profileNodeModules = join(profilePath, 'node_modules')
731
+ const [versionBytes, profileBytes, profileNodeModulesBytes, versionPackages, profilePackages] = await Promise.all([
732
+ directoryBytes(versionPath),
733
+ directoryBytes(profilePath),
734
+ directoryBytes(profileNodeModules),
735
+ packageSizesIn(join(versionPath, 'node_modules')),
736
+ packageSizesIn(profileNodeModules),
737
+ ])
738
+ const sum = (map) => [...map.values()].reduce((totalBytes, bytes) => totalBytes + bytes, 0)
739
+ const direct = new Set(Object.keys(profileDependencies(profilePath)))
740
+ const { plugins, depsBytes: pluginDepsBytes } = await attributePlugins(profileNodeModules, [...direct], profilePackages)
741
+ let pluginOwnBytes = 0
742
+ for (const name of direct) pluginOwnBytes += profilePackages.get(name) || 0
743
+ let runtimeBytes = 0
744
+ let runtimeDepsBytes = 0
745
+ for (const [name, bytes] of versionPackages) {
746
+ if (name.startsWith('@deepseek-ai/')) runtimeBytes += bytes
747
+ else runtimeDepsBytes += bytes
748
+ }
749
+ const dataBytes = Math.max(0, profileBytes - profileNodeModulesBytes)
750
+ const total = versionBytes + profileBytes
751
+ // 到不了的 profile 包(残留/未声明)和版本目录非 node_modules 的部分都算「未归类」
752
+ const restBytes = Math.max(0, total - pluginOwnBytes - pluginDepsBytes - runtimeBytes - runtimeDepsBytes - dataBytes)
753
+ const segments = [
754
+ { key: 'plugin', bytes: pluginOwnBytes },
755
+ { key: 'plugin-deps', bytes: pluginDepsBytes },
756
+ { key: 'runtime', bytes: runtimeBytes },
757
+ { key: 'runtime-deps', bytes: runtimeDepsBytes },
758
+ { key: 'data', bytes: dataBytes },
759
+ { key: 'rest', bytes: restBytes },
760
+ ].filter((segment) => segment.bytes >= 1)
761
+ const combined = new Map()
762
+ for (const [name, bytes] of versionPackages) combined.set(name, { name, bytes, plugin: false })
763
+ for (const [name, bytes] of profilePackages) {
764
+ const item = combined.get(name) || { name, bytes: 0, plugin: false }
765
+ item.bytes += bytes
766
+ item.plugin = item.plugin || direct.has(name)
767
+ combined.set(name, item)
768
+ }
769
+ const items = [...combined.values()].sort((left, right) => right.bytes - left.bytes).slice(0, 5)
770
+ const data = { version, profile, total, segments, plugins, items }
771
+ storageBreakdownCache.set(key, { at: Date.now(), data })
772
+ return data
773
+ }
774
+
775
+ function invalidateStorageBreakdown() {
776
+ storageBreakdownCache.clear()
777
+ }
778
+
779
+ /** 已装版本各自占多少(只算 DATA/versions 下受管版本;系统全局装的不归启动器管)。 */
780
+ async function versionSizes() {
781
+ if (versionSizesCache.data && Date.now() - versionSizesCache.at < VERSION_SIZE_TTL_MS) return versionSizesCache.data
782
+ const versions = scanInstalled().filter(isManaged)
783
+ const sizes = {}
784
+ for (const version of versions) sizes[version] = await directoryBytes(versionDir(version))
785
+ const total = Object.values(sizes).reduce((sum, bytes) => sum + bytes, 0)
786
+ versionSizesCache = { at: Date.now(), data: { versions: sizes, total } }
787
+ return versionSizesCache.data
788
+ }
789
+
528
790
  function listedVersions(config) {
529
791
  const onDisk = new Set(scanInstalled())
530
792
  const fromConfig = (config.versions || [])
@@ -605,6 +867,8 @@ async function pruneVersions(config) {
605
867
  }
606
868
  }
607
869
  if (removed.length) {
870
+ versionSizesCache.at = 0
871
+ invalidateStorageBreakdown()
608
872
  config.versions = listedVersions(config)
609
873
  await saveConfig(config)
610
874
  for (const version of removed) await dropInstancePorts(version)
@@ -1543,7 +1807,7 @@ async function setInstancePortNow(version, profile, port) {
1543
1807
  ? { environmentPorts: { ...ENVIRONMENT_PORTS, [home]: next } }
1544
1808
  : { instancePorts: next })
1545
1809
  INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
1546
- ENVIRONMENT_PORTS = safeEnvironmentPorts(saved.environmentPorts)
1810
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
1547
1811
  }
1548
1812
  const text = String(port ?? '').trim()
1549
1813
  if (!text || Number(text) === 0) {
@@ -1842,10 +2106,40 @@ async function removeLaunchPresetNow(id) {
1842
2106
  const stored = await loadSettings()
1843
2107
  if (installing || packInstalling) throw new Error('正在安装,稍后再编辑启动项')
1844
2108
  const presets = safeLaunchPresets(stored.launchPresets)
2109
+ const entry = presets.find((item) => item.id === id)
1845
2110
  const next = presets.filter((item) => item.id !== id)
1846
- if (next.length === presets.length) throw new Error('这个启动项已不存在,请刷新后重试')
1847
- // 删除快捷入口不改实例端口,避免影响同一组合的手动启动设置。
1848
- await saveSettings({ launchPresets: next })
2111
+ if (!entry) throw new Error('这个启动项已不存在,请刷新后重试')
2112
+ // 启动项是端口钉子的唯一入口:它删掉、又没有别的启动项用同一个版本@profile 时,
2113
+ // 钉子一起清掉,否则端口会被一个已经不存在的启动项永久占住(#90)。
2114
+ const home = entry.dshHome || defaultHome()
2115
+ const profile = entry.profile || 'web'
2116
+ const keys = new Set([entry.version !== 'auto' ? entry.version : '', entry.usedVersion, entry.prevVersion]
2117
+ .filter(Boolean).map((version) => `${version}@${profile}`))
2118
+ for (const other of next) {
2119
+ if (!fileManager.samePath(other.dshHome || defaultHome(), home)) continue
2120
+ for (const version of [other.version !== 'auto' ? other.version : '', other.usedVersion, other.prevVersion]) {
2121
+ if (version) keys.delete(`${version}@${other.profile || 'web'}`)
2122
+ }
2123
+ }
2124
+ const patch = { launchPresets: next }
2125
+ if (keys.size) {
2126
+ if (fileManager.samePath(home, defaultHome())) {
2127
+ const ports = { ...safeInstancePorts(stored.instancePorts) }
2128
+ for (const key of keys) delete ports[key]
2129
+ patch.instancePorts = ports
2130
+ } else {
2131
+ const environments = safeEnvironmentPorts(stored.environmentPorts)
2132
+ const dir = Object.keys(environments).find((item) => fileManager.samePath(item, home))
2133
+ if (dir) {
2134
+ const ports = { ...environments[dir] }
2135
+ for (const key of keys) delete ports[key]
2136
+ patch.environmentPorts = { ...environments, [dir]: ports }
2137
+ }
2138
+ }
2139
+ }
2140
+ const saved = await saveSettings(patch)
2141
+ INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
2142
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
1849
2143
  dropLaunchLog(id)
1850
2144
  await emitState()
1851
2145
  return { ok: true }
@@ -1891,7 +2185,7 @@ async function dropInstancePorts(version) {
1891
2185
  && Object.entries(environments).every(([home, ports]) => Object.keys(ports).length === Object.keys(ENVIRONMENT_PORTS[home]).length)) return
1892
2186
  const saved = await saveSettings({ instancePorts: kept, environmentPorts: environments })
1893
2187
  INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
1894
- ENVIRONMENT_PORTS = safeEnvironmentPorts(saved.environmentPorts)
2188
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
1895
2189
  }
1896
2190
 
1897
2191
  async function saveManagerSettings(body) {
@@ -2040,7 +2334,7 @@ async function saveManagerSettings(body) {
2040
2334
  EXTRA_ARGS = composeExtraArgs(stored.args)
2041
2335
  WEB_BIND = safeWebBind(stored.webBind)
2042
2336
  INSTANCE_PORTS = safeInstancePorts(stored.instancePorts)
2043
- ENVIRONMENT_PORTS = safeEnvironmentPorts(stored.environmentPorts)
2337
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(stored)
2044
2338
  if (safeLang(stored.lang)) LANG = safeLang(stored.lang)
2045
2339
  THEME = safeTheme(stored.theme)
2046
2340
  PANEL_TRANSPARENCY = safePanelTransparency(stored.panelTransparency)
@@ -2951,7 +3245,7 @@ function autoPortCandidates(stored, version, profile) {
2951
3245
  /** 这个端口这次真的能拿来复用吗(不能的话调用方会换一个新的)。 */
2952
3246
  async function autoPortUsable(port, stored) {
2953
3247
  // 自动端口不能抢手工指定给其他实例的端口;真实监听也要检查(含别的程序)。
2954
- const reserved = [stored.instancePorts, ...Object.values(stored.environmentPorts || {})]
3248
+ const reserved = [stored.instancePorts, ...Object.values(liveEnvironmentPorts(stored))]
2955
3249
  .some((ports) => Object.values(ports || {}).includes(port))
2956
3250
  || safeLaunchPresets(stored.launchPresets).some((entry) => entry.port === port)
2957
3251
  // 检查监听会让出执行权;不同目录的并发启动不能同时复用同一个历史端口。
@@ -3964,6 +4258,7 @@ async function addPlugin(version, spec, { profile = PROFILE_NAME } = {}) {
3964
4258
  pluginBusy = false
3965
4259
  pluginProgressName = ''
3966
4260
  pluginProgressProfile = ''
4261
+ invalidateStorageBreakdown()
3967
4262
  emitPluginProgress(null)
3968
4263
  }
3969
4264
  }
@@ -4006,6 +4301,7 @@ async function removePlugin(version, name, { profile = PROFILE_NAME } = {}) {
4006
4301
  pluginBusy = false
4007
4302
  pluginProgressName = ''
4008
4303
  pluginProgressProfile = ''
4304
+ invalidateStorageBreakdown()
4009
4305
  emitPluginProgress(null)
4010
4306
  }
4011
4307
  }
@@ -4224,6 +4520,7 @@ async function runProfileInstall(version, options = {}) {
4224
4520
  } finally {
4225
4521
  pluginProgressName = ''
4226
4522
  pluginProgressProfile = ''
4523
+ invalidateStorageBreakdown()
4227
4524
  emitPluginProgress(null)
4228
4525
  }
4229
4526
  }
@@ -5742,7 +6039,7 @@ async function renameProfile(profile, nextName) {
5742
6039
  autoPorts: Object.fromEntries(Object.entries(safeEnvironmentPorts(current.autoPorts)).map(([dir, value]) => [dir, sameHome(dir) ? ports(value) : value])),
5743
6040
  }))
5744
6041
  INSTANCE_PORTS = safeInstancePorts(saved.instancePorts)
5745
- ENVIRONMENT_PORTS = safeEnvironmentPorts(saved.environmentPorts)
6042
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(saved)
5746
6043
  if (sameHome(defaultHome())) {
5747
6044
  PROFILE_NAME = saved.profile
5748
6045
  }
@@ -6780,11 +7077,16 @@ async function handleApi(req, res, url) {
6780
7077
  }
6781
7078
  if (req.method === 'POST' && url.pathname === '/api/install') {
6782
7079
  await install(body.version)
7080
+ versionSizesCache.at = 0
7081
+ invalidateStorageBreakdown()
6783
7082
  send(res, 200, { ok: true })
6784
7083
  return
6785
7084
  }
6786
7085
  if (req.method === 'POST' && url.pathname === '/api/versions/optimize') {
6787
- send(res, 200, await optimizeVersion(body.version))
7086
+ const result = await optimizeVersion(body.version)
7087
+ versionSizesCache.at = 0
7088
+ invalidateStorageBreakdown()
7089
+ send(res, 200, result)
6788
7090
  return
6789
7091
  }
6790
7092
  if (req.method === 'POST' && url.pathname === '/api/start') {
@@ -6820,8 +7122,16 @@ async function handleApi(req, res, url) {
6820
7122
  // 启动项仍在用的目录不能悄悄消失,也不能为了移除列表而删掉启动项。
6821
7123
  if (removing && safeLaunchPresets(current.launchPresets).some((entry) => entry.dshHome && same(entry.dshHome, home))) throw new Error('启动项正在使用此目录,请先修改或删除对应启动项')
6822
7124
  const homes = safeDshHomes(current.dshHomes).filter((item) => !same(item, home))
6823
- return { dshHomes: removing ? homes : [...homes, home] }
7125
+ return {
7126
+ dshHomes: removing ? homes : [...homes, home],
7127
+ // 目录都不在了,钉在它上面的端口也没意义;留着会占住端口(#90)。
7128
+ ...(removing ? {
7129
+ environmentPorts: Object.fromEntries(Object.entries(safeEnvironmentPorts(current.environmentPorts))
7130
+ .filter(([dir]) => !same(dir, home))),
7131
+ } : {}),
7132
+ }
6824
7133
  })
7134
+ if (removing) ENVIRONMENT_PORTS = liveEnvironmentPorts(stored)
6825
7135
  send(res, 200, { dshHomes: safeDshHomes(stored.dshHomes) })
6826
7136
  await emitState()
6827
7137
  return
@@ -6907,9 +7217,25 @@ async function handleApi(req, res, url) {
6907
7217
  }
6908
7218
  if (req.method === 'POST' && url.pathname === '/api/uninstall') {
6909
7219
  await uninstall(body.version)
7220
+ versionSizesCache.at = 0
7221
+ invalidateStorageBreakdown()
6910
7222
  send(res, 200, { ok: true })
6911
7223
  return
6912
7224
  }
7225
+ if (req.method === 'GET' && url.pathname === '/api/versions/sizes') {
7226
+ try { send(res, 200, await versionSizes()) }
7227
+ catch (error) { send(res, 500, { error: error instanceof Error ? error.message : String(error) }) }
7228
+ return
7229
+ }
7230
+ if (req.method === 'GET' && url.pathname === '/api/storage/breakdown') {
7231
+ try {
7232
+ const version = safeVersion(url.searchParams.get('version') || '')
7233
+ const profile = safeProfile(url.searchParams.get('profile') || PROFILE_NAME)
7234
+ if (!existsSync(versionDir(version))) throw new Error(`${version} 没有安装在启动器的版本目录里`)
7235
+ send(res, 200, await storageBreakdown(version, profile))
7236
+ } catch (error) { send(res, 400, { error: error instanceof Error ? error.message : String(error) }) }
7237
+ return
7238
+ }
6913
7239
  if (req.method === 'POST' && url.pathname === '/api/settings') {
6914
7240
  send(res, 200, await saveManagerSettings(body))
6915
7241
  return
@@ -7045,6 +7371,7 @@ async function handleApi(req, res, url) {
7045
7371
  }
7046
7372
  } finally {
7047
7373
  pluginBusy = false
7374
+ invalidateStorageBreakdown()
7048
7375
  }
7049
7376
  pushLog(`桌面增强:${profile} 卸载完成,成功 ${removed.length} 个${failed.length ? `,失败 ${failed.length} 个` : ''}(重启 dsh 后生效)`)
7050
7377
  send(res, 200, { ok: failed.length === 0, removed, failed, desktopEnhance: desktopEnhanceSummary(profile), ...listPlugins(profileDirOf(profile)), profile })
@@ -7784,7 +8111,13 @@ export async function startServer() {
7784
8111
  }
7785
8112
  // 钉死端口的实例表:启动、重启都读它(页面改这张表走 /api/instance-port)
7786
8113
  INSTANCE_PORTS = safeInstancePorts(stored.instancePorts)
7787
- ENVIRONMENT_PORTS = safeEnvironmentPorts(stored.environmentPorts)
8114
+ // 旧环境留下的端口钉子:内存里不算它,文件里也顺手清掉,不然重装也不会消失(#90)。
8115
+ const storedEnvironments = safeEnvironmentPorts(stored.environmentPorts)
8116
+ ENVIRONMENT_PORTS = liveEnvironmentPorts(stored)
8117
+ if (Object.keys(ENVIRONMENT_PORTS).length !== Object.keys(storedEnvironments).length) {
8118
+ await saveSettings({ environmentPorts: ENVIRONMENT_PORTS }).catch(() => {})
8119
+ pushLog('已清理旧环境留下的固定端口记录')
8120
+ }
7788
8121
  // 用户在设置页的选择要跨重启与升级保留,安装语言只初始化缺失的设置。
7789
8122
  const storedLang = safeLang(stored.lang)
7790
8123
  const fromInstall = storedLang ? '' : installLang()