@cowwo/dsh-lookout 0.1.2 → 0.5.0

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/client/client.js CHANGED
@@ -5039,13 +5039,22 @@ window.__ModuleLoader__.load({
5039
5039
  //
5040
5040
  // 这一页只回答两个问题:
5041
5041
  // 1. 配好了吗 → 分组状态点(已配置 / 未配置 / 不适用)
5042
- // 2. 能发出去吗 → 顶部状态条 + 发送测试邮件 + 最近一次投递结果
5042
+ // 2. 能发出去吗 → 顶部状态条 + 发送测试邮件(点了当场回执成败与原始报错)
5043
5043
  // 其余一切让路:不额外套卡片、不加编号、不做中英同屏。
5044
5044
  //
5045
- // 编辑模型:**没有保存/放弃按钮,改完即时生效**。按字段类型区别对待:
5046
- // - 开关 / 下拉 / 分段:立刻写
5047
- // - 数字 / 文本:防抖(停手再写)+ 失焦兜底
5048
- // - 密码:只在失焦或回车时写,且**每个凭据引用名各写各的**
5045
+ // 信息架构(本轮只重做视觉骨架,信息架构与功能不动):
5046
+ // 内容列 max-width 760 居中;页标题 18/600 + 说明 12/18;右上只有动作组
5047
+ // (脏读数 + 保存 + 放弃改动),它是动作区、不是第二层页头;
5048
+ // 判决条 = 状态色 hero(bg-module-platform + 左侧 3px 状态色 + 18/600 标题 + 主按钮在右下);
5049
+ // 下面三张描边卡片:收件人 / 怎么发 / 什么时候发给我(14/22·500 卡片标题 + 12/18 状态读数);
5050
+ // 7 个预设收成一行横向滚动 chips;端口 / 加密折进「更多」(收起时标题带当前值);
5051
+ // 8 个事件开关排成宿主同款行(16px 上下内边距 + 行自身 .5px 线),说明由问号**点击**展开。
5052
+ // 字号阶梯全部落在宿主网格上(只用整像素档,不再有半档字号);圆角只用 4/8/12/16/20/28。
5053
+ //
5054
+ // 编辑模型:**显式保存**。所有控件只改本地草稿,点「保存」才一次性写回:
5055
+ // - 配置字段:与基线不同的项合成一次 mutate(found.ns, ops, revision)
5056
+ // - 授权码:草稿只活在内存里,保存成功时才写凭据库(写入目标由那一行决定)
5057
+ // - 「放弃改动」把草稿整体回滚到基线;「恢复全部默认」不受保存按钮约束,立即生效
5049
5058
  //
5050
5059
  // 六条硬约束(前三条踩过,后三条是本轮修掉的):
5051
5060
  // 1. 注册页面只依赖 `slots`。远程命名空间 / locale 各开一个非阻塞的 ctx.inject 去填;
@@ -5077,24 +5086,25 @@ window.__ModuleLoader__.load({
5077
5086
  /** 只等必需服务:页面注册本身只需要 slots(硬约束 1)。 */
5078
5087
  const inject = ['slots'];
5079
5088
 
5080
- const NS = 'dsh-notify-p';
5089
+ /**
5090
+ * 文案字典的命名空间(宿主的 profile 入口 id)。
5091
+ *
5092
+ * **不是设置命名空间**:配置的 ns 每次都由 describe() 返回项里的 schema 签名认领
5093
+ * (见 load()),客户端不允许写死——profile 入口 id 换了,这里的所有读写都得跟着换。
5094
+ */
5095
+ const LOCALE_NS = 'lookout';
5081
5096
 
5082
5097
  /** Host Remote 的命名空间(与 lib/typert.host.js 清单一致)。 */
5083
5098
  const REMOTE_NS = 'mailNotify';
5084
5099
 
5085
- /** 文本 / 数字字段的防抖时长(毫秒)。 */
5086
- const TEXT_DEBOUNCE_MS = 600;
5087
- const NUMBER_DEBOUNCE_MS = 500;
5088
-
5089
5100
  // ---------------------------------------------------------------------------
5090
5101
  // 文案:zh 是内置兜底,en 走 locale 字典;界面永远只显示一种语言(不同屏并列)。
5091
5102
  // ---------------------------------------------------------------------------
5092
5103
  const ZH = {
5093
5104
  'group.status': '投递状态',
5094
- 'evidence.label': '最近一次发送',
5095
- 'evidence.never': '还没有试过',
5096
- 'evidence.ok': '成功,用时 {ms} 毫秒,{ago}',
5097
- 'evidence.fail': '失败',
5105
+ 'test.sent': '已发送',
5106
+ 'test.sentMs': '用时 {ms} 毫秒',
5107
+ 'test.failed': '这次没发出去',
5098
5108
  'cred.saved': '已保存',
5099
5109
  'cred.notSaved': '还没保存',
5100
5110
  'cred.replace': '更换',
@@ -5103,7 +5113,7 @@ window.__ModuleLoader__.load({
5103
5113
  'err.auth': '服务端拒绝了这组账号密码',
5104
5114
  'err.authHint': '多半是授权码不对或过期了:QQ 邮箱要的是「授权码」而不是登录密码,重新生成一个再填。',
5105
5115
  'err.rejected': '发件人被拒',
5106
- 'err.rejectedHint': '收件服务器不接受这个发件人。换个发件地址(一般在「发件人与链接」里)再试。',
5116
+ 'err.rejectedHint': '收件服务器不接受这个发件人。发件地址由插件按服务端设置决定,确认这个邮箱被允许用来发信后再试。',
5107
5117
  'err.network': '连不上服务器',
5108
5118
  'err.networkHint': '检查主机名与端口;本机防火墙或公司网络也可能挡住 465 / 587。',
5109
5119
  'err.other': '发送失败',
@@ -5112,11 +5122,24 @@ window.__ModuleLoader__.load({
5112
5122
  nav: '邮件通知',
5113
5123
  lead: '会话干完活、出错或卡住时,给你发一封邮件。',
5114
5124
 
5115
- 'save.auto': '改动即时生效',
5116
- 'save.saving': '正在保存…',
5117
- 'save.savedAgo': '已保存,{n} 秒前',
5125
+ 'save.clean': '没有未保存的改动',
5126
+ 'save.dirty': '有 {n} 项改动未保存',
5127
+ 'save.saving': '保存中…',
5128
+ 'save.savedJust': '已保存 · 刚刚',
5129
+ 'save.savedAgo': '已保存 · {n} 秒前',
5118
5130
  'save.error': '保存失败:{error}',
5131
+ 'save.retry': '重试',
5119
5132
  'save.reload': '重新读取',
5133
+ // 读失败是**独立的一档**:它不是"保存失败",重试也不该走保存那条路。
5134
+ 'save.loadFailed': '读不到本插件的配置:{error}',
5135
+ // 冲突恢复:别人先改了 → 已自动重读,草稿没动,让用户再点一次。
5136
+ 'save.conflictReloaded': '配置已被别处改动,已读到最新值,请再点一次保存',
5137
+ // 半成功:必须点出**哪一半**成功、哪一半没成,别用一句"保存失败"把成功的半吞掉。
5138
+ 'save.credFailed': '配置已保存,但授权码没写进去:{error}',
5139
+ 'save.configFailed': '授权码已保存,但配置没写进去:{error}',
5140
+ 'save.credAlsoFailed': '授权码也没写进去:{error}',
5141
+ 'save.retryCred': '重试授权码',
5142
+ 'save.retryConfig': '重试配置',
5120
5143
  'save.checkingCred': '正在确认授权码是否已写入…',
5121
5144
  'save.credCheckFailed': '凭据状态读取失败:{error}',
5122
5145
 
@@ -5126,7 +5149,6 @@ window.__ModuleLoader__.load({
5126
5149
  'status.needUser': '还差 SMTP 用户名',
5127
5150
  'status.needCredential': '还差{cred}',
5128
5151
  'status.ready': '可以发信了',
5129
- 'status.lastFailed': '上次发信失败',
5130
5152
  'status.notServed': '这一页读不到本插件的配置',
5131
5153
 
5132
5154
  // 判决下面那句人话。只说用户的事,不说实现的事。
@@ -5136,7 +5158,8 @@ window.__ModuleLoader__.load({
5136
5158
  'status.why.needUser': '多数邮箱要求用户名和授权码成对出现。',
5137
5159
  'status.why.needCredential': '没填它,服务端会拒收这封信。',
5138
5160
  'status.why.ready': '配置齐了。发一封测试邮件,确认服务端真的收下。',
5139
- 'status.why.lastFailed': '配置看着没问题,但上一次没发出去。',
5161
+ // 判决条只按已保存的配置判定,这句把口径说透,免得"我明明填好了"变成困惑。
5162
+ 'status.unsavedDraft': '未保存的改动不参与判定。',
5140
5163
 
5141
5164
  'act.enable': '启用',
5142
5165
  'act.goFill': '去填写',
@@ -5144,15 +5167,15 @@ window.__ModuleLoader__.load({
5144
5167
  'act.testing': '正在发送…',
5145
5168
  'act.retest': '重新测试',
5146
5169
  'act.needsRestart': '需要重启 DSH 后才能测试',
5147
- 'act.testHint': '按现在的配置真发一封到收件人,不用等某个会话干完。',
5170
+ // 明说用**哪一份**配置:测试走的是已保存配置,草稿还没落盘,别让人以为测的是屏幕上这些字。
5171
+ 'act.testHint': '用已保存的配置真发一封到收件人,不用等某个会话干完。',
5172
+ 'act.save': '保存',
5173
+ 'act.discard': '放弃改动',
5174
+ 'act.saveHint': '把这一页的改动一次性写回配置。',
5148
5175
 
5149
5176
  'group.recipients': '收件人',
5150
5177
  'group.transport': '怎么发',
5151
5178
  'group.outcomes': '什么时候发给我',
5152
- 'group.optional': '可选设置',
5153
- 'group.sender': '发件人与链接',
5154
- 'group.advanced': '高级',
5155
-
5156
5179
 
5157
5180
  'recipients.label': '收件邮箱',
5158
5181
  'recipients.placeholder': 'you@qq.com',
@@ -5161,12 +5184,6 @@ window.__ModuleLoader__.load({
5161
5184
  'recipients.none': '还没填',
5162
5185
  'recipients.invalid': '格式不对,不会被发送:{list}',
5163
5186
 
5164
- 'time.justNow': '刚刚',
5165
- 'time.secondsAgo': '{n} 秒前',
5166
- 'time.minutesAgo': '{n} 分钟前',
5167
- 'time.hoursAgo': '{n} 小时前',
5168
- 'time.daysAgo': '{n} 天前',
5169
-
5170
5187
  'transportState.done': '已配好',
5171
5188
  'transportState.todo': '还没配好',
5172
5189
 
@@ -5175,6 +5192,12 @@ window.__ModuleLoader__.load({
5175
5192
  'preset.label': '邮箱服务商',
5176
5193
  'preset.other': '其他',
5177
5194
  'preset.detected': '已按「{name}」填好',
5195
+ // 端口 / 加密收进「更多」:收起来不等于藏起来——偏离宿主默认就把现值摆到标题上。
5196
+ 'transport.more': '更多',
5197
+ 'transport.moreWith': '更多({value})',
5198
+ // 事件开关的问号按钮:只做可访问名(说明正文仍走 outcomes.*Hint)。
5199
+ 'hint.show': '展开「{title}」的说明',
5200
+ 'hint.hide': '收起「{title}」的说明',
5178
5201
 
5179
5202
  'smtp.host': 'SMTP 主机',
5180
5203
  'smtp.port': '端口',
@@ -5183,7 +5206,7 @@ window.__ModuleLoader__.load({
5183
5206
  'smtp.accountMismatch': '{host} 是{name}的服务器,但账号是 {user}:对不上,认证会失败。把账号换成 {suffix} 结尾的邮箱,或把服务商改成账号所属的那一家。',
5184
5207
 
5185
5208
  'cred.smtp': 'SMTP 授权码',
5186
- 'cred.smtpHint': '不是登录密码。只写进凭据库,永不回显;离开输入框时保存。',
5209
+ 'cred.smtpHint': '不是登录密码。只写进凭据库,永不回显;点「保存」时才写入。',
5187
5210
  'cred.smtpHelp': '怎么拿授权码?',
5188
5211
  'cred.smtpHelpTitle': '怎么拿 SMTP 授权码',
5189
5212
  'cred.smtpHelpBody': '授权码不是登录密码,是邮箱服务商单独发的口令(QQ 是 16 位)。\n\nQQ 邮箱:设置 → 账户 → 开启「POP3/SMTP 服务」→ 生成授权码。\n163 / 126:设置 → POP3/SMTP/IMAP → 开启服务 → 新增授权码。\nGmail:账号 → 安全性 → 两步验证 → 应用专用密码。\niCloud:Apple ID → 登录与安全 → App 专用密码。\nOutlook:账户 → 安全性 → 高级安全选项 → 应用密码。',
@@ -5207,52 +5230,12 @@ window.__ModuleLoader__.load({
5207
5230
  'outcomes.interruptedHint': 'DSH 重启 / 热更新 / 被父级取消。',
5208
5231
  'outcomes.maxTokens': '达到长度上限',
5209
5232
  'outcomes.maxTokensHint': '回复被长度上限截断。',
5210
- 'outcomes.minDuration': '完成时长下限',
5211
- 'outcomes.minDurationHint': '短于这个时长的完成不发信;只对「任务完成」生效,出错、卡住、审批不受此限。',
5212
5233
  'outcomes.subagents': '也通知子代理会话',
5213
5234
  'outcomes.subagentsHint': '一个 workflow 能扇出几十个子会话,默认不打扰。',
5214
5235
 
5215
- 'sender.name': '发件人显示名',
5216
- 'sender.nameHint': '收件人看到的发件人名字,留空就用默认名。',
5217
- 'sender.address': '发件人地址',
5218
- 'sender.addressHint': '必须是能用的邮箱地址;格式不对或与清单里已有的重复,会在弹层里就地报错。',
5219
- 'sender.listLabel': '发件人',
5220
- 'sender.listHint': '这里保存所有发件人,发信时用选中的那一条。点某一行选中它,✎ 改,× 删。',
5221
- 'sender.empty': '还没有保存发件人 · 将回退到默认发件人',
5222
- 'sender.add': '+ 添加发件人',
5223
- 'sender.addTitle': '添加发件人',
5224
- 'sender.editTitle': '编辑发件人',
5225
- 'sender.save': '保存',
5226
- 'sender.cancel': '取消',
5227
- 'sender.inUse': '发信时用它',
5228
- 'sender.edit': '编辑',
5229
- 'sender.remove': '删除',
5230
- 'sender.nameUnset': '(用默认显示名)',
5231
- 'sender.useThis': '用 {address} 发信',
5232
- 'sender.issue.needAddress': '请填发件人地址',
5233
- 'sender.issue.badAddress': '地址格式不对,不会被接受',
5234
- 'sender.issue.duplicate': '这个地址已经在清单里了',
5235
-
5236
- 'link.enabled': '邮件里带直达会话链接',
5237
- 'link.enabledHint': '需要另装深链插件才能被识别。',
5238
- 'link.base': '链接对外地址',
5239
- 'link.baseHint': '留空则读 DSH_WEB_URL;手机要能点开就填局域网地址或域名。',
5240
-
5241
- 'adv.settle': '合并窗口',
5242
- 'adv.settleHint': '毫秒。期间同一会话又开新轮,就合并成一封。',
5243
- 'adv.rate': '发送速率上限',
5244
- 'adv.rateHint': '封/分钟,多会话并停时保护邮箱服务商。',
5245
- 'adv.pwRef': 'SMTP 凭据引用名',
5246
- 'adv.pwRefHint': '凭据库里的键名,改这里不会动已存的值。',
5247
- 'adv.statePath': '投递账本路径',
5248
- 'adv.statePathHint': '留空 = $DSH_HOME/dsh-notify-p/state.json。排错时 cat 它。',
5249
-
5250
- 'unit.ms': '毫秒',
5251
- 'unit.perMin': '封/分钟',
5252
-
5253
5236
  'danger.reset': '恢复全部默认',
5254
5237
  'danger.title': '恢复全部默认?',
5255
- 'danger.body': '这会清掉本插件在 settings.yaml 里的全部覆盖(收件人、事件开关、节奏参数都会回到默认)。',
5238
+ 'danger.body': '这会清掉本插件在 profile 里的全部覆盖(收件人、事件开关、节奏参数都会回到默认),并立即生效——它不受「保存」按钮约束,这一页还没保存的草稿也会一起丢掉。',
5256
5239
  'danger.keep': '凭据库里已存的授权码不受影响。',
5257
5240
  'danger.ok': '恢复默认',
5258
5241
  'danger.cancel': '取消',
@@ -5260,10 +5243,9 @@ window.__ModuleLoader__.load({
5260
5243
 
5261
5244
  const EN = {
5262
5245
  'group.status': 'Delivery status',
5263
- 'evidence.label': 'Last send',
5264
- 'evidence.never': 'Not tried yet',
5265
- 'evidence.ok': 'Sent in {ms} ms, {ago}',
5266
- 'evidence.fail': 'Failed',
5246
+ 'test.sent': 'Sent',
5247
+ 'test.sentMs': 'in {ms} ms',
5248
+ 'test.failed': 'This send did not go out',
5267
5249
  'cred.saved': 'Saved',
5268
5250
  'cred.notSaved': 'Not saved yet',
5269
5251
  'cred.replace': 'Replace',
@@ -5272,7 +5254,7 @@ window.__ModuleLoader__.load({
5272
5254
  'err.auth': 'The server rejected these credentials',
5273
5255
  'err.authHint': 'Usually a wrong or expired authorization code: QQ Mail needs an app code, not your login password. Generate a new one and paste it here.',
5274
5256
  'err.rejected': 'Sender rejected',
5275
- 'err.rejectedHint': 'The receiving server will not accept this sender. Change the From address under "Sender and links" and retry.',
5257
+ 'err.rejectedHint': 'The receiving server will not accept this sender. The From address comes from the plugin’s server-side settings; check that the mailbox is allowed to send.',
5276
5258
  'err.network': 'Cannot reach the server',
5277
5259
  'err.networkHint': 'Check the host and port; a local firewall or corporate network may block 465 / 587.',
5278
5260
  'err.other': 'Send failed',
@@ -5281,11 +5263,21 @@ window.__ModuleLoader__.load({
5281
5263
  nav: 'Mail notification',
5282
5264
  lead: 'Get one email when a session finishes, fails, or gets stuck.',
5283
5265
 
5284
- 'save.auto': 'Changes apply immediately',
5266
+ 'save.clean': 'No unsaved changes',
5267
+ 'save.dirty': 'Unsaved changes: {n}',
5285
5268
  'save.saving': 'Saving…',
5286
- 'save.savedAgo': 'Saved, {n}s ago',
5269
+ 'save.savedJust': 'Saved · just now',
5270
+ 'save.savedAgo': 'Saved · {n}s ago',
5287
5271
  'save.error': 'Save failed: {error}',
5272
+ 'save.retry': 'Retry',
5288
5273
  'save.reload': 'Reload',
5274
+ 'save.loadFailed': 'Cannot read this plugin’s settings: {error}',
5275
+ 'save.conflictReloaded': 'The settings were changed elsewhere. The latest values are loaded — press Save again.',
5276
+ 'save.credFailed': 'Settings saved, but the authorization code was not written: {error}',
5277
+ 'save.configFailed': 'Authorization code saved, but the settings were not written: {error}',
5278
+ 'save.credAlsoFailed': 'The authorization code was not written either: {error}',
5279
+ 'save.retryCred': 'Retry the code',
5280
+ 'save.retryConfig': 'Retry the settings',
5289
5281
  'save.checkingCred': 'Checking whether the code was written…',
5290
5282
  'save.credCheckFailed': 'Could not read the credential state: {error}',
5291
5283
 
@@ -5295,7 +5287,6 @@ window.__ModuleLoader__.load({
5295
5287
  'status.needUser': 'SMTP user name missing',
5296
5288
  'status.needCredential': '{cred} missing',
5297
5289
  'status.ready': 'Ready to send',
5298
- 'status.lastFailed': 'The last send failed',
5299
5290
  'status.notServed': 'This page cannot read the plugin settings',
5300
5291
 
5301
5292
  'status.why.disabled': 'Turn on any case below and notifications start.',
@@ -5304,7 +5295,7 @@ window.__ModuleLoader__.load({
5304
5295
  'status.why.needUser': 'Most providers want the user name and the authorization code together.',
5305
5296
  'status.why.needCredential': 'Without it the server will refuse the message.',
5306
5297
  'status.why.ready': 'Configuration is complete. Send a test email to confirm the server accepts it.',
5307
- 'status.why.lastFailed': 'The configuration looks fine, but the last attempt did not go out.',
5298
+ 'status.unsavedDraft': 'Unsaved changes are not part of this verdict.',
5308
5299
 
5309
5300
  'act.enable': 'Turn on',
5310
5301
  'act.goFill': 'Fill it in',
@@ -5312,15 +5303,14 @@ window.__ModuleLoader__.load({
5312
5303
  'act.testing': 'Sending…',
5313
5304
  'act.retest': 'Test again',
5314
5305
  'act.needsRestart': 'Restart DSH to test',
5315
- 'act.testHint': 'Really sends one email to the recipients right now, without waiting for a session to finish.',
5306
+ 'act.testHint': 'Really sends one email to the recipients with the saved configuration, without waiting for a session to finish.',
5307
+ 'act.save': 'Save',
5308
+ 'act.discard': 'Discard changes',
5309
+ 'act.saveHint': 'Writes every change on this page back in one go.',
5316
5310
 
5317
5311
  'group.recipients': 'Recipients',
5318
5312
  'group.transport': 'How to send',
5319
5313
  'group.outcomes': 'When to notify me',
5320
- 'group.optional': 'Optional',
5321
- 'group.sender': 'Sender and links',
5322
- 'group.advanced': 'Advanced',
5323
-
5324
5314
 
5325
5315
  'recipients.label': 'Recipient addresses',
5326
5316
  'recipients.placeholder': 'you@qq.com, one per line',
@@ -5329,12 +5319,6 @@ window.__ModuleLoader__.load({
5329
5319
  'recipients.none': 'None yet',
5330
5320
  'recipients.invalid': 'Bad format, will not be sent: {list}',
5331
5321
 
5332
- 'time.justNow': 'just now',
5333
- 'time.secondsAgo': '{n}s ago',
5334
- 'time.minutesAgo': '{n} min ago',
5335
- 'time.hoursAgo': '{n} h ago',
5336
- 'time.daysAgo': '{n} d ago',
5337
-
5338
5322
  'transportState.done': 'Configured',
5339
5323
  'transportState.todo': 'Not configured yet',
5340
5324
 
@@ -5343,6 +5327,10 @@ window.__ModuleLoader__.load({
5343
5327
  'preset.label': 'Mail provider',
5344
5328
  'preset.other': 'Other',
5345
5329
  'preset.detected': 'Filled in for “{name}”',
5330
+ 'transport.more': 'More',
5331
+ 'transport.moreWith': 'More ({value})',
5332
+ 'hint.show': 'Show the note for “{title}”',
5333
+ 'hint.hide': 'Hide the note for “{title}”',
5346
5334
 
5347
5335
  'smtp.host': 'SMTP host',
5348
5336
  'smtp.port': 'Port',
@@ -5351,7 +5339,7 @@ window.__ModuleLoader__.load({
5351
5339
  'smtp.accountMismatch': '{host} is {name}’s server but the account is {user} — they do not match, so authentication will fail. Use a {suffix} address, or switch the provider to the account’s own.',
5352
5340
 
5353
5341
  'cred.smtp': 'SMTP authorization code',
5354
- 'cred.smtpHint': 'Not your login password. Written to the credential store only, never echoed; saved when you leave the field.',
5342
+ 'cred.smtpHint': 'Not your login password. Written to the credential store only, never echoed; written when you press Save.',
5355
5343
  'cred.smtpHelp': 'How do I get one?',
5356
5344
  'cred.smtpHelpTitle': 'Getting an SMTP authorization code',
5357
5345
  'cred.smtpHelpBody': 'An authorization code is not your login password — the mail provider issues it separately.\n\nQQ Mail: Settings → Account → enable “POP3/SMTP service” → generate the code.\n163 / 126: Settings → POP3/SMTP/IMAP → enable the service → add an authorization code.\nGmail: Account → Security → 2-Step Verification → App passwords.\niCloud: Apple ID → Sign-In and Security → App-Specific Passwords.\nOutlook: Account → Security → Advanced security options → App password.',
@@ -5375,52 +5363,12 @@ window.__ModuleLoader__.load({
5375
5363
  'outcomes.interruptedHint': 'Harness restart / hot reload / cancelled by a parent.',
5376
5364
  'outcomes.maxTokens': 'Token limit reached',
5377
5365
  'outcomes.maxTokensHint': 'The reply was cut off by the length limit.',
5378
- 'outcomes.minDuration': 'Minimum duration',
5379
- 'outcomes.minDurationHint': 'Completions shorter than this are not sent; applies to “Task completed” only — failures, blocks and approvals always get through.',
5380
5366
  'outcomes.subagents': 'Also notify subagent sessions',
5381
5367
  'outcomes.subagentsHint': 'One workflow can fan out dozens of sub-sessions; off by default.',
5382
5368
 
5383
- 'sender.name': 'Sender display name',
5384
- 'sender.nameHint': 'The name your recipients see; leave empty to use the default.',
5385
- 'sender.address': 'From address',
5386
- 'sender.addressHint': 'Must be a usable email address; a bad one or a duplicate is reported in the dialog.',
5387
- 'sender.listLabel': 'Senders',
5388
- 'sender.listHint': 'All senders are kept here; the selected one is used to send. Click a row to select it, ✎ to edit, × to remove.',
5389
- 'sender.empty': 'No sender saved yet · will fall back to the default sender',
5390
- 'sender.add': '+ Add sender',
5391
- 'sender.addTitle': 'Add sender',
5392
- 'sender.editTitle': 'Edit sender',
5393
- 'sender.save': 'Save',
5394
- 'sender.cancel': 'Cancel',
5395
- 'sender.inUse': 'In use',
5396
- 'sender.edit': 'Edit',
5397
- 'sender.remove': 'Remove',
5398
- 'sender.nameUnset': '(uses the default name)',
5399
- 'sender.useThis': 'Send from {address}',
5400
- 'sender.issue.needAddress': 'A from address is required',
5401
- 'sender.issue.badAddress': 'Not a valid email address',
5402
- 'sender.issue.duplicate': 'This address is already in the list',
5403
-
5404
- 'link.enabled': 'Include a link to the session',
5405
- 'link.enabledHint': 'Needs a deep-link plugin installed to be recognized.',
5406
- 'link.base': 'Public base URL',
5407
- 'link.baseHint': 'Empty reads DSH_WEB_URL. To open links on your phone, put a LAN address or domain here.',
5408
-
5409
- 'adv.settle': 'Coalesce window',
5410
- 'adv.settleHint': 'Milliseconds. A new turn in the same session during this window is folded into one email.',
5411
- 'adv.rate': 'Send rate limit',
5412
- 'adv.rateHint': 'Emails per minute; protects your mail provider when many sessions stop at once.',
5413
- 'adv.pwRef': 'SMTP credential reference',
5414
- 'adv.pwRefHint': 'The key name in the credential store. Renaming does not touch the stored value.',
5415
- 'adv.statePath': 'Delivery ledger path',
5416
- 'adv.statePathHint': 'Empty = $DSH_HOME/dsh-notify-p/state.json. cat it when debugging.',
5417
-
5418
- 'unit.ms': 'ms',
5419
- 'unit.perMin': 'per min',
5420
-
5421
5369
  'danger.reset': 'Reset everything to defaults',
5422
5370
  'danger.title': 'Reset everything to defaults?',
5423
- 'danger.body': 'This clears every override this plugin stored in settings.yaml — recipients, event switches and pacing all return to defaults.',
5371
+ 'danger.body': 'This clears every override this plugin stored in the profile — recipients, event switches and pacing all return to defaults — and takes effect immediately, outside the Save button. Unsaved drafts on this page are dropped as well.',
5424
5372
  'danger.keep': 'Stored authorization codes are not affected.',
5425
5373
  'danger.ok': 'Reset',
5426
5374
  'danger.cancel': 'Cancel',
@@ -5464,6 +5412,12 @@ window.__ModuleLoader__.load({
5464
5412
  { id: 'icloud', name: 'iCloud', host: 'smtp.mail.me.com', port: 587, secure: 'starttls', suffix: '@icloud.com', domains: ['icloud.com', 'me.com', 'mac.com'] },
5465
5413
  ];
5466
5414
 
5415
+ /**
5416
+ * 端口 / 加密的宿主默认值(逐字对齐 lib/notify/config.js:端口 465、加密 auto)。
5417
+ * 「更多」折叠标题靠它判断这一对值是不是偏离默认,偏离才在后面写当前值。
5418
+ */
5419
+ const DEFAULT_TRANSPORT = { port: 465, secure: 'auto' };
5420
+
5467
5421
  /**
5468
5422
  * 解析收件人:一行一个(兼容逗号/分号),去空、去重、丢弃明显不含 @ 的项。
5469
5423
  * 规则必须与 Host 侧 lib/config.js 的 parseRecipients 保持一致。
@@ -5487,97 +5441,6 @@ window.__ModuleLoader__.load({
5487
5441
  return { valid, invalid };
5488
5442
  }
5489
5443
 
5490
- /**
5491
- * 归一化发件人清单,并把老配置迁移成清单。
5492
- * 规则必须与 Host 侧 lib/config.js 的 senderList 保持一致(迁移、去空、去重)。
5493
- * @param {object} config 当前草稿
5494
- * @returns {{ name: string, address: string }[]} 清单(可能为空)
5495
- */
5496
- function migrateSenders(config) {
5497
- const raw = Array.isArray(config?.senders) ? config.senders : [];
5498
- const list = [];
5499
- const seen = new Set();
5500
- for (const entry of raw) {
5501
- const address = String(entry?.address ?? '').trim();
5502
- if (address === '') continue;
5503
- const key = address.toLowerCase();
5504
- if (seen.has(key)) continue;
5505
- seen.add(key);
5506
- list.push({ name: String(entry?.name ?? '').trim(), address });
5507
- }
5508
- if (list.length > 0) return list;
5509
- const address = String(config?.fromAddress ?? '').trim();
5510
- if (address === '') return [];
5511
- return [{ name: String(config?.senderName ?? '').trim(), address }];
5512
- }
5513
-
5514
- /**
5515
- * 哪一条是当前生效的。
5516
- * 与 Host 侧 resolveSender 同规则:按**地址**匹配,找不到就退回第一条。
5517
- * @param {object} config 当前草稿
5518
- * @param {{name: string, address: string}[]} list 已归一化的清单
5519
- * @returns {string} 生效的地址;清单为空时 ''
5520
- */
5521
- function activeSenderAddress(config, list) {
5522
- const wanted = String(config?.activeSender ?? '').trim().toLowerCase();
5523
- const hit = list.find((entry) => entry.address.toLowerCase() === wanted);
5524
- return (hit ?? list[0])?.address ?? '';
5525
- }
5526
-
5527
- /**
5528
- * 校验一条发件人。
5529
- * @param {{name: string, address: string}[]} list 现有清单
5530
- * @param {number} editing 正在编辑的下标;等于 list.length 表示新增
5531
- * @param {{name: string, address: string}} value 待保存的值
5532
- * @returns {''|'needAddress'|'badAddress'|'duplicate'} '' 表示通过
5533
- */
5534
- function senderIssue(list, editing, value) {
5535
- const address = String(value?.address ?? '').trim();
5536
- if (address === '') return 'needAddress';
5537
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) return 'badAddress';
5538
- const key = address.toLowerCase();
5539
- for (let i = 0; i < list.length; i += 1) {
5540
- if (i !== editing && list[i].address.toLowerCase() === key) return 'duplicate';
5541
- }
5542
- return '';
5543
- }
5544
-
5545
- /**
5546
- * 保存一条(新增或就地修改)。
5547
- * 新增的**自动成为生效项**;改地址时若它正是生效项,生效项跟着新地址走。
5548
- * @param {{list: object[], active: string, editing: number}} state 当前状态
5549
- * @param {{name: string, address: string}} value 待保存的值
5550
- * @returns {{list: object[], active: string}} 新状态
5551
- */
5552
- function senderCommit(state, value) {
5553
- const list = state.list.map((entry) => ({ ...entry }));
5554
- const name = String(value?.name ?? '').trim();
5555
- const address = String(value?.address ?? '').trim();
5556
- if (state.editing >= list.length) {
5557
- list.push({ name, address });
5558
- return { list, active: address };
5559
- }
5560
- const before = list[state.editing].address;
5561
- list[state.editing] = { name, address };
5562
- return { list, active: state.active === before ? address : state.active };
5563
- }
5564
-
5565
- /**
5566
- * 删一条,并决定谁来接替生效位。
5567
- * 删掉的正是生效项时,选它下面那条(没有下面则选最后一条);删光则清空生效位。
5568
- * @param {{list: object[], active: string}} state 当前状态
5569
- * @param {number} index 要删的下标
5570
- * @returns {{list: object[], active: string}} 新状态
5571
- */
5572
- function senderRemove(state, index) {
5573
- const list = state.list.map((entry) => ({ ...entry }));
5574
- const wasActive = list[index].address === state.active;
5575
- list.splice(index, 1);
5576
- if (list.length === 0) return { list, active: '' };
5577
- if (!wasActive) return { list, active: state.active };
5578
- return { list, active: list[Math.min(index, list.length - 1)].address };
5579
- }
5580
-
5581
5444
  /**
5582
5445
  * 把投递失败的原因翻译成「该动哪里」。
5583
5446
  *
@@ -5605,25 +5468,6 @@ window.__ModuleLoader__.load({
5605
5468
  };
5606
5469
  }
5607
5470
 
5608
- /**
5609
- * 「多久以前」的人话。只给投递结果用,所以粒度到分钟就够。
5610
- * @param {unknown} at 时间戳(毫秒)
5611
- * @param {number} now 当前时间戳
5612
- * @returns {string} 如「刚刚」「3 分钟前」
5613
- */
5614
- function relativeTime(at, now) {
5615
- const stamp = typeof at === 'number' && Number.isFinite(at) ? at : null;
5616
- if (stamp === null) return '';
5617
- const seconds = Math.max(0, Math.round((now - stamp) / 1000));
5618
- if (seconds < 10) return i18n.t('time.justNow');
5619
- if (seconds < 60) return i18n.t('time.secondsAgo', { n: seconds });
5620
- const minutes = Math.round(seconds / 60);
5621
- if (minutes < 60) return i18n.t('time.minutesAgo', { n: minutes });
5622
- const hours = Math.round(minutes / 60);
5623
- if (hours < 24) return i18n.t('time.hoursAgo', { n: hours });
5624
- return i18n.t('time.daysAgo', { n: Math.round(hours / 24) });
5625
- }
5626
-
5627
5471
  /**
5628
5472
  * 按主机名匹配预设。
5629
5473
  * @param {unknown} host SMTP 主机
@@ -5679,14 +5523,13 @@ window.__ModuleLoader__.load({
5679
5523
  return hit === undefined ? '' : hit;
5680
5524
  }
5681
5525
 
5682
- /** 状态条的取值顺序:先看能不能发,再看有没有真的发出去过。 */
5526
+ /** 状态条的取值只由「配置齐不齐」决定;投递历史已随账本一起删除。 */
5683
5527
  const STATUS_TONE = {
5684
5528
  disabled: 'idle',
5685
5529
  needRecipients: 'warning',
5686
5530
  needHost: 'warning',
5687
5531
  needUser: 'warning',
5688
5532
  needCredential: 'warning',
5689
- lastFailed: 'error',
5690
5533
  ready: 'done',
5691
5534
  };
5692
5535
 
@@ -5694,22 +5537,43 @@ window.__ModuleLoader__.load({
5694
5537
  * 推导状态条要显示哪个结论。纯函数,输入全部来自草稿与凭据状态。
5695
5538
  * SMTP 是唯一的发送方式,必填项是「主机 + 用户名 + 凭据」,缺哪个就报哪个——
5696
5539
  * 只报主机会让用户填完主机才发现还是发不出去。
5540
+ * 投递历史不参与:账本已删,这里只回答「配置齐不齐」。
5697
5541
  * @param {object} input 判定输入
5698
5542
  * @returns {string} 状态键
5699
5543
  */
5700
5544
  function deriveStatus(input) {
5701
- const { enabled, recipientCount, host, user, credentialConfigured, lastDelivery } = input ?? {};
5545
+ const { enabled, recipientCount, host, user, credentialConfigured } = input ?? {};
5702
5546
  if (enabled !== true) return 'disabled';
5703
5547
  if (!(recipientCount > 0)) return 'needRecipients';
5704
5548
  if (String(host ?? '').trim() === '') return 'needHost';
5705
5549
  if (String(user ?? '').trim() === '') return 'needUser';
5706
5550
  if (credentialConfigured !== true) return 'needCredential';
5707
- if (lastDelivery && lastDelivery.ok === false) return 'lastFailed';
5708
5551
  return 'ready';
5709
5552
  }
5710
5553
 
5711
5554
  /**
5712
- * 某一组的状态点。optional 组只区分「已自定义 / 走默认」。
5555
+ * 草稿与基线的差异 → mutate 的 ops。
5556
+ *
5557
+ * 一次保存只发一条 mutate:ops 里只放与基线不同的字段,空值走 unset(清掉覆盖)。
5558
+ * @param {object} draft 当前草稿
5559
+ * @param {object} base 上一次落盘的基线
5560
+ * @returns {Array<{op: string, path: string[], value?: unknown}>} ops
5561
+ */
5562
+ function diffOps(draft, base) {
5563
+ const left = draft && typeof draft === 'object' ? draft : {};
5564
+ const right = base && typeof base === 'object' ? base : {};
5565
+ const ops = [];
5566
+ for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
5567
+ const value = left[key];
5568
+ if (value === right[key]) continue;
5569
+ if (value === '' || value === null || value === undefined) ops.push({ op: 'unset', path: [key] });
5570
+ else ops.push({ op: 'set', path: [key], value });
5571
+ }
5572
+ return ops;
5573
+ }
5574
+
5575
+ /**
5576
+ * 某一组的状态点。不适用时给 idle——这一组本来就不参与,别画成告警。
5713
5577
  * @param {boolean} done 是否已配置好
5714
5578
  * @param {boolean} [notApplicable] 是否不适用
5715
5579
  * @returns {'done'|'warning'|'idle'} 状态点状态
@@ -5736,65 +5600,127 @@ window.__ModuleLoader__.load({
5736
5600
  // 面板是它的后代,直接引用按继承链正常求值,暗色自动跟着走。
5737
5601
  //
5738
5602
  // 量度(全部照抄宿主自己的设置页,别自己发明):
5739
- // 内容列 ≈ 564px · 段标题 14/22·500 · 行标题 14/22 · 字段标签 12/18·500
5740
- // 说明 12/18 tertiary · 行间 .5px border-l2 · 段头下 .5px border-l3
5603
+ // 内容列 max-width 760 居中 · 页标题 18/26·600 · 卡片标题 14/22·500 · 行标题 14/22·400
5604
+ // 字段标签 13/1.5·500(primary) · 说明 12/18 tertiary · 小读数 11/16
5605
+ // 卡片 = .5px border-l4 + radius 20 + padding 14 16 + gap 12;卡片之间 gap 12
5606
+ // 行 = padding 16px 0 + 行自身 .5px border-l2(最后一行没有);段头 / 页脚一律不画线
5741
5607
  // 填充面 = bg-module-platform(宿主"编辑器/选中态"用的就是这个)
5742
5608
  //
5743
5609
  // 纪律:全页零硬编码颜色(单测里有审计)。**全页只有一个填充面 —— 判决条。**
5744
5610
  // ---------------------------------------------------------------------------
5745
5611
 
5746
5612
  /** 一眼看出浏览器加载的是哪一版。放在根节点的 data-build 上,检查 DOM 可见,不占视觉。 */
5747
- const BUILD = 'ui11';
5748
-
5749
- const CSS = `
5750
- /* dsh-notify-p · 设置页样式(量度对齐宿主 settings 页;全页零硬编码颜色) */
5751
- .dshmn-page{display:flex;flex-direction:column;min-width:0;max-width:100%;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
5752
- .dshmn-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
5753
-
5754
- /* 页头:一句用途 + 落盘读数(读数只报事实,不报"操作成功") */
5755
- .dshmn-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin:0 0 14px;min-width:0}
5756
- .dshmn-lead{margin:0;min-width:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5757
- .dshmn-stamp{flex:none;display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5613
+ const BUILD = 'ui17';
5614
+
5615
+ const CSS = `/* dsh-notify-p · 设置页样式(视觉骨架照抄宿主 settings 页;全页零硬编码颜色)
5616
+ 字号阶梯(每一档都落在宿主网格上,全页只用整像素档,不许半档):
5617
+ 页标题 18/26·600 = 判决标题 18/26·600 > 卡片标题 14/22·500
5618
+ > 字段标签 13/1.5·500 > 行标题 14/22·400 > 说明 12/18 > 小读数 11/16
5619
+ 骨架:内容列 max-width 760 居中;页头只有标题 + 动作组;三张描边卡片;
5620
+ 判决条是唯一填充面(bg-module-platform)+ 左侧 3px 状态色;页脚无边框。
5621
+ 线只长在行自己身上(.5px border-l2,最后一行没有),段头与页脚一律不画线。
5622
+ 圆角只用宿主那几档:卡片 / 判决条 20、输入框 12、chips 8、问号 50%。 */
5623
+ .dshmn-page{display:flex;flex-direction:column;width:100%;max-width:760px;margin:0 auto;min-width:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
5624
+ .dshmn-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
5625
+
5626
+ /* 页头:标题 18/600 独占一行,右上只有动作组(脏读数 + 保存 + 放弃改动)。
5627
+ 不画线、不放说明、不占第二层高度——它是内容区第一行,不是第二条标题栏。 */
5628
+ .dshmn-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px 16px;margin:0 0 24px;min-width:0;flex-wrap:wrap}
5629
+ .dshmn-headText{display:flex;flex-direction:column;gap:12px;min-width:0;flex:1 1 auto}
5630
+ .dshmn-title{margin:0;min-width:0;color:var(--dsw-alias-label-primary);font-size:18px;line-height:26px;font-weight:600}
5631
+ .dshmn-lead{margin:0;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5632
+ .dshmn-headActions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex:none;flex-wrap:wrap;min-width:0}
5633
+ .dshmn-stamp{flex:none;display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
5758
5634
  .dshmn-stamp[data-tone=busy]{color:var(--dsw-alias-label-secondary)}
5759
5635
  .dshmn-stamp[data-tone=error]{color:var(--dsw-alias-state-error-primary)}
5760
-
5761
- /* ── 判决条:全页唯一的填充面 ────────────────────────────────────────────── */
5762
- .dshmn-verdict{display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:12px;min-width:0;
5763
- background:var(--dsw-alias-bg-module-platform);border:.5px solid var(--dsw-alias-border-l3)}
5636
+ .dshmn-stamp[data-tone=dirty]{color:var(--dsw-alias-label-primary)}
5637
+
5638
+ /* ── 判决条:全页唯一的填充面,也是唯一的状态色 hero ─────────────────────
5639
+ 左侧 3px 竖条与标题色都由 data-tone 决定,颜色全部来自 state token(不硬编码)。
5640
+ 三态:可发信 = success / 需补配置 = warn / 已关闭 = label-tertiary。 */
5641
+ /* 按钮:按宿主线上规则(.primary/.outline + .sm)自绘 —— 见 Button() 注释里为什么不能等宿主原语 */
5642
+ .dshmn-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;box-sizing:border-box;
5643
+ height:28px;padding:0 10px;border:none;border-radius:8px;font-family:inherit;
5644
+ font-size:12px;line-height:18px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-primary)}
5645
+ .dshmn-btn[data-variant=primary]{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
5646
+ .dshmn-btn[data-variant=primary]:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
5647
+ .dshmn-btn[data-variant=outline]{border:.5px solid var(--dsw-alias-border-l3)}
5648
+ .dshmn-btn[data-variant=outline]:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
5649
+ .dshmn-btn:disabled{opacity:.5;cursor:not-allowed}
5650
+ .dshmn-btn:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:2px}
5651
+
5652
+ .dshmn-verdict{display:flex;flex-direction:column;gap:8px;min-width:0;padding:18px 20px 16px;border-radius:20px;
5653
+ background:var(--dsw-alias-bg-module-platform);border-left:3px solid var(--dsw-alias-label-tertiary)}
5654
+ .dshmn-verdict[data-tone=done]{border-left-color:var(--dsw-alias-state-success-primary)}
5655
+ .dshmn-verdict[data-tone=warning]{border-left-color:var(--dsw-alias-state-warn-primary)}
5656
+ .dshmn-verdict[data-tone=idle]{border-left-color:var(--dsw-alias-label-tertiary)}
5657
+ .dshmn-verdict[data-tone=error]{border-left-color:var(--dsw-alias-state-error-primary)}
5764
5658
  .dshmn-verdictHead{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}
5765
- .dshmn-verdictTitle{margin:0;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;line-height:24px;font-weight:500}
5766
- .dshmn-verdictWhy{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
5767
- .dshmn-verdictFoot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:2px}
5768
- .dshmn-evidence{display:flex;flex-direction:column;gap:2px;margin-top:2px;padding-top:10px;min-width:0;
5769
- border-top:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
5659
+ .dshmn-verdictTitle{margin:0;min-width:0;color:var(--dsw-alias-label-primary);font-size:18px;line-height:26px;font-weight:600}
5660
+ .dshmn-verdict[data-tone=done] .dshmn-verdictTitle{color:var(--dsw-alias-state-success-primary)}
5661
+ .dshmn-verdict[data-tone=warning] .dshmn-verdictTitle{color:var(--dsw-alias-state-warn-primary)}
5662
+ .dshmn-verdict[data-tone=idle] .dshmn-verdictTitle{color:var(--dsw-alias-label-tertiary)}
5663
+ .dshmn-verdict[data-tone=error] .dshmn-verdictTitle{color:var(--dsw-alias-state-error-primary)}
5664
+ .dshmn-verdictWhy{margin:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5665
+ .dshmn-verdictFoot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:4px}
5666
+ .dshmn-evidence{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
5770
5667
  .dshmn-evidenceKey{color:var(--dsw-alias-label-tertiary)}
5771
5668
  .dshmn-evidence[data-ok=false] .dshmn-evidenceKey{color:var(--dsw-alias-state-error-primary)}
5772
5669
  .dshmn-evidence .dshmn-mono{color:var(--dsw-alias-label-secondary);word-break:break-all}
5773
5670
 
5774
- /* ── 分组:段头 + 发丝线。不做卡片、不编号 ──────────────────────────────── */
5775
- .dshmn-group{display:flex;flex-direction:column;margin-top:24px;min-width:0}
5776
- .dshmn-groupHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;
5777
- padding-bottom:6px;border-bottom:.5px solid var(--dsw-alias-border-l3)}
5778
- .dshmn-groupTitle{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:500}
5779
- .dshmn-groupState{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:22px;font-variant-numeric:tabular-nums}
5780
- .dshmn-groupBody{display:flex;flex-direction:column;min-width:0}
5781
-
5782
- /* ── 条目:分隔线长在条目上,这样"父行 + 缩进子行"之间只画一条线 ─────────── */
5783
- .dshmn-item{display:flex;flex-direction:column;gap:8px;padding:12px 0;min-width:0;
5671
+ /* ── 三张卡片:靠 .5px 描边成立,不靠底色(浅色主题下所有 bg-layer 都是纯白)──
5672
+ 描边 + radius 20 + padding 14 16 + 内部 gap 12,与宿主 settings 的 rowCard 同构。 */
5673
+ .dshmn-card{display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 16px;
5674
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:20px}
5675
+ .dshmn-card + .dshmn-card{margin-top:12px}
5676
+ .dshmn-verdict + .dshmn-card{margin-top:16px}
5677
+ .dshmn-cardHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;min-width:0}
5678
+ .dshmn-cardTitle{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:500}
5679
+ .dshmn-cardState{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
5680
+ .dshmn-cardBody{display:flex;flex-direction:column;min-width:0}
5681
+ /* 卡片自己已经给了 14px 上内边距:里面的第一个元素不要再叠一层上边距 */
5682
+ .dshmn-cardBody>:first-child{padding-top:0}
5683
+ .dshmn-cardBody>:last-child{padding-bottom:0;border-bottom:none}
5684
+
5685
+ /* ── 行:线只长在行自己身上(.5px border-l2,最后一行没有),上下各 16px ────── */
5686
+ .dshmn-item{display:flex;flex-direction:column;gap:8px;padding:16px 0;min-width:0;
5784
5687
  border-bottom:.5px solid var(--dsw-alias-border-l2)}
5785
5688
  .dshmn-item:last-child{padding-bottom:0;border-bottom:none}
5786
- .dshmn-itemHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;min-width:0}
5787
- .dshmn-itemText{display:flex;flex-direction:column;gap:4px;flex:1 1 auto;min-width:0}
5788
- .dshmn-itemTitle{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
5789
- .dshmn-itemCtrl{flex:none;display:flex;align-items:center;gap:8px;min-height:22px}
5790
- /* 缩进子行:把"它只对上面那一项生效"画成结构,而不是再写一句说明 */
5791
- .dshmn-sub{display:flex;flex-direction:column;gap:8px;margin-left:3px;padding-left:12px;min-width:0;
5689
+ .dshmn-itemHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}
5690
+ .dshmn-labelRow{display:inline-flex;align-items:center;gap:6px;min-width:0}
5691
+
5692
+ /* ── 开关行:标题 14/22·400 + 问号 + 开关。说明折进问号里,默认不占版面 ───────── */
5693
+ .dshmn-row{display:flex;flex-direction:column;gap:8px;padding:16px 0;min-width:0;
5694
+ border-bottom:.5px solid var(--dsw-alias-border-l2)}
5695
+ .dshmn-row:last-child{padding-bottom:0;border-bottom:none}
5696
+ .dshmn-rowHead{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}
5697
+ .dshmn-rowTitle{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:400}
5698
+ .dshmn-rowName{min-width:0}
5699
+ .dshmn-rowCtrl{flex:none;display:flex;align-items:center;gap:8px;min-height:22px}
5700
+ .dshmn-rowNote{margin:0;max-width:44em;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5701
+ /* 问号:原生 button(点击开合,触屏点得到,键盘 tab 得到),16×16 只占一个行内空位 */
5702
+ .dshmn-q{flex:none;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;
5703
+ width:16px;height:16px;padding:0;cursor:pointer;
5704
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:50%;background:none;
5705
+ color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;line-height:1}
5706
+ .dshmn-q:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
5707
+ .dshmn-q[aria-expanded=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);
5708
+ border-color:var(--dsw-alias-border-l3)}
5709
+
5710
+ /* ── 折叠标题(「更多」「还有 3 种情况」):一行文字按钮,展开后缩进一道竖线 ── */
5711
+ .dshmn-fold{display:inline-flex;align-items:center;align-self:flex-start;gap:4px;padding:0;cursor:pointer;
5712
+ border:none;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;
5713
+ text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--dsw-alias-border-l4)}
5714
+ .dshmn-fold:hover{color:var(--dsw-alias-label-primary)}
5715
+ .dshmn-fold[aria-expanded=true]{color:var(--dsw-alias-label-primary);text-decoration:none}
5716
+ .dshmn-foldBody{display:flex;flex-direction:column;gap:8px;min-width:0;margin-top:8px;padding-left:12px;
5792
5717
  border-left:.5px solid var(--dsw-alias-border-l2)}
5793
- .dshmn-sub[data-muted=true]{opacity:.5}
5718
+ .dshmn-q:focus-visible,.dshmn-fold:focus-visible,.dshmn-link:focus-visible,.dshmn-segBtn:focus-visible{
5719
+ outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
5794
5720
 
5795
- /* ── 字段:标签在上、控件满宽(宿主 settings-models 的 fieldLabel 同款) ── */
5796
- .dshmn-field{display:flex;flex-direction:column;gap:6px;min-width:0}
5797
- .dshmn-label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;font-weight:500}
5721
+ /* ── 字段:标签在上、控件满宽(宿主 settings 的字段同款)────────────────────── */
5722
+ .dshmn-field{display:flex;flex-direction:column;gap:8px;min-width:0}
5723
+ .dshmn-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;font-weight:500}
5798
5724
  .dshmn-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;max-width:44em}
5799
5725
  .dshmn-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;word-break:break-word}
5800
5726
  .dshmn-credState{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
@@ -5803,94 +5729,58 @@ window.__ModuleLoader__.load({
5803
5729
 
5804
5730
  /* ── 输入 ──────────────────────────────────────────────────────────────── */
5805
5731
  /* 宿主的 Input 原语是 content-box 的 inline-flex(padding 0 8px + .5px 描边),
5806
- 所以光给 width:100% 会让它比父级宽出 18px、顶出内容列。必须一起给 border-box。 */
5732
+ 所以光给 width:100% 会让它比父级宽出 18px、顶出内容列。必须一起给 border-box。
5733
+ 这里是原语缺席时的兜底输入框:底色 / 描边 / 圆角与宿主 Input 对齐(bg-layer-3 + border-l4 + 12)。 */
5807
5734
  .dshmn-inputWrap{box-sizing:border-box;display:flex;width:100%;min-width:0}
5808
5735
  .dshmn-inputWrap>*{box-sizing:border-box;width:100%;min-width:0}
5809
- .dshmn-textarea{box-sizing:border-box;width:100%;min-height:64px;padding:8px 10px;resize:vertical;outline:none;
5810
- border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);
5736
+ .dshmn-textarea{box-sizing:border-box;width:100%;min-height:52px;padding:8px 10px;resize:vertical;outline:none;
5737
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-3);
5811
5738
  color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px}
5812
5739
  .dshmn-textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
5813
- .dshmn-select{box-sizing:border-box;width:100%;height:36px;padding:0 10px;outline:none;
5814
- border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);
5815
- color:var(--dsw-alias-label-primary);font:inherit;font-size:14px}
5740
+ .dshmn-select{box-sizing:border-box;width:100%;height:32px;padding:0 10px;outline:none;
5741
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-3);
5742
+ color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px}
5816
5743
  .dshmn-textarea:focus-visible,.dshmn-select:focus-visible{border-color:var(--dsw-alias-brand-primary);
5817
5744
  box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}
5818
5745
  .dshmn-textarea[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary)}
5819
5746
 
5820
- /* ── 发件人清单:一行一个身份,选中用 module 面(与 segBtn 的选中语言一致)──── */
5821
- /* 清单本身是个带描边的容器,条目之间只画发丝线——和 Group 的语言保持一致。 */
5822
- .dshmn-senders{display:flex;flex-direction:column;border:.5px solid var(--dsw-alias-border-l4);
5823
- border-radius:8px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
5824
- .dshmn-sender{display:flex;align-items:center;gap:10px;padding:0 6px 0 10px;min-height:46px;cursor:pointer;
5825
- border-bottom:.5px solid var(--dsw-alias-border-l2)}
5826
- .dshmn-sender:last-child{border-bottom:none}
5827
- .dshmn-sender:hover{background:var(--dsw-alias-interactive-bg-hover)}
5828
- .dshmn-sender[data-active=true]{background:var(--dsw-alias-bg-module-platform)}
5829
- .dshmn-senderRadio{flex:none;width:15px;height:15px;margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
5830
- .dshmn-senderBody{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;padding:7px 0}
5831
- .dshmn-senderName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
5832
- color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px}
5833
- .dshmn-sender[data-active=true] .dshmn-senderName{font-weight:500}
5834
- .dshmn-senderAddr{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
5835
- color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5836
- .dshmn-senderBadge{flex:none;color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:18px}
5837
- .dshmn-senderIcon{flex:none;box-sizing:border-box;width:26px;height:26px;display:inline-flex;align-items:center;
5838
- justify-content:center;padding:0;cursor:pointer;border:none;border-radius:6px;background:none;
5839
- color:var(--dsw-alias-label-tertiary);font:inherit;font-size:15px;line-height:1}
5840
- .dshmn-senderIcon:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5841
- .dshmn-senderIcon:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
5842
- .dshmn-senderEmpty{padding:12px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:22px}
5843
- .dshmn-senderAdd{box-sizing:border-box;align-self:flex-start;display:inline-flex;align-items:center;gap:6px;
5844
- margin:8px 10px;height:30px;padding:0 13px;cursor:pointer;font:inherit;font-size:13px;line-height:18px;
5845
- border:.5px dashed var(--dsw-alias-border-l4);border-radius:15px;background:transparent;
5846
- color:var(--dsw-alias-label-secondary)}
5847
- .dshmn-senderAdd:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5848
- .dshmn-senderAdd:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
5849
-
5850
- /* SMTP 三个短字段排一行;窄屏折成一列——别竖着排三个满宽输入。 */
5851
- .dshmn-grid{display:grid;grid-template-columns:minmax(0,1fr) 88px 104px;gap:10px;align-items:start;min-width:0}
5852
- .dshmn-num{display:inline-flex;align-items:center;gap:6px;min-width:0}
5853
- .dshmn-numBox{width:78px;min-width:0}
5854
- .dshmn-numBox>*,.dshmn-numBox input{box-sizing:border-box;width:100%;min-width:0}
5855
- .dshmn-unit{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5856
-
5857
- /* ── 单选:宿主"外观"三张卡的形态 —— 未选=白底+发丝线,选中=module 面 ──── */
5858
- .dshmn-seg{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
5859
- .dshmn-segBtn{box-sizing:border-box;height:32px;padding:0 14px;cursor:pointer;font:inherit;font-size:13px;line-height:20px;
5860
- border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-1);
5747
+ /* 两列:主机 + 用户名。端口 / 加密在「更多」里,同样两列,只是端口窄一点。 */
5748
+ .dshmn-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px 16px;align-items:start;min-width:0}
5749
+ .dshmn-twoPort{display:grid;grid-template-columns:96px minmax(0,1fr);gap:12px 16px;align-items:start;min-width:0}
5750
+
5751
+ /* ── 预设 chips:一行横向滚动(窄屏也不折成三排),滚动条藏起来 ─────────────── */
5752
+ .dshmn-chips{display:flex;align-items:center;gap:8px;min-width:0;overflow-x:auto;overflow-y:hidden;padding:2px 0;
5753
+ scrollbar-width:none;-ms-overflow-style:none}
5754
+ .dshmn-chips::-webkit-scrollbar{display:none}
5755
+ .dshmn-segBtn{box-sizing:border-box;flex:none;height:28px;padding:0 12px;cursor:pointer;white-space:nowrap;
5756
+ font:inherit;font-size:12px;line-height:18px;
5757
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:none;
5861
5758
  color:var(--dsw-alias-label-secondary)}
5862
5759
  .dshmn-segBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5863
- .dshmn-segBtn[data-active=true]{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-border-l3);
5864
- color:var(--dsw-alias-label-primary);font-weight:500}
5865
- .dshmn-segBtn:focus-visible,.dshmn-link:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
5760
+ .dshmn-segBtn[data-active=true]{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);font-weight:600;
5761
+ color:var(--dsw-alias-label-primary);font-weight:600}
5866
5762
 
5867
5763
  /* ── 文字按钮 ──────────────────────────────────────────────────────────── */
5868
- .dshmn-link{color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;
5764
+ .dshmn-link{color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;
5869
5765
  padding:0;border:none;background:none;text-decoration:underline;text-underline-offset:3px;
5870
5766
  text-decoration-color:var(--dsw-alias-border-l4)}
5871
5767
  .dshmn-link:hover{color:var(--dsw-alias-label-primary)}
5872
5768
  .dshmn-link:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;text-decoration:none}
5873
5769
 
5874
- /* ── 折叠区与页脚 ──────────────────────────────────────────────────────── */
5875
- .dshmn-disclose{margin-top:0}
5876
- .dshmn-discloseBody{display:flex;flex-direction:column;gap:4px;padding-top:10px;min-width:0}
5877
- /* 展开后的内容:缩进 + 一道左竖线,跟 SMTP 子块同一套语言——
5878
- 折叠区打开后必须一眼看出"这些东西属于上面那一行",而不是又一组平铺的设置。 */
5879
- .dshmn-stack{display:flex;flex-direction:column;gap:14px;min-width:0;margin-top:10px;
5880
- padding-left:12px;border-left:1px solid var(--dsw-alias-border-l2)}
5881
- /* 内容自带行节奏时(Row 自己有内边距和发丝线)只保留缩进,别再叠一层间距 */
5882
- .dshmn-stack[data-rows=true]{gap:0}
5883
- .dshmn-foot{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap;margin-top:24px;
5884
- padding-top:14px;border-top:.5px solid var(--dsw-alias-border-l3)}
5885
- .dshmn-helpBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px}
5770
+ /* ── 页脚与帮助正文:页脚不画线,只留一条 12/18 的链接 ───────────────────── */
5771
+ .dshmn-foot{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap;margin-top:16px;padding-top:16px}
5772
+ .dshmn-foot .dshmn-link{font-size:12px;line-height:18px}
5773
+ .dshmn-helpBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5886
5774
 
5887
5775
  /* ── 窄屏:宿主 settings 页的断点就是 560px ─────────────────────────────── */
5888
5776
  @media (max-width:560px){
5889
- .dshmn-head{flex-direction:column;gap:4px}
5777
+ .dshmn-head{flex-direction:column;align-items:stretch;gap:12px}
5778
+ .dshmn-headActions{justify-content:flex-start}
5890
5779
  .dshmn-verdictFoot{justify-content:flex-start}
5891
- .dshmn-grid{grid-template-columns:minmax(0,1fr)}
5892
- .dshmn-numBox{width:auto;flex:1 1 auto}
5780
+ .dshmn-two,.dshmn-twoPort{grid-template-columns:minmax(0,1fr)}
5781
+ .dshmn-card{padding:12px 14px}
5893
5782
  }
5783
+
5894
5784
  `;
5895
5785
 
5896
5786
 
@@ -5936,10 +5826,25 @@ window.__ModuleLoader__.load({
5936
5826
  return h('span', { 'aria-hidden': 'true' }, '✓');
5937
5827
  }
5938
5828
 
5939
- /** 按钮。 */
5829
+ /**
5830
+ * 按钮。
5831
+ *
5832
+ * 实测:当前宿主版本**没有** P.Button 这个原语(web bundle 里只有 MenuItemButton),
5833
+ * 所以兜底分支才是实际走的那条。之前它退化成 .dshmn-link(带下划线的文字),
5834
+ * 页面上因此一个"有体量"的按钮都没有——这是整页显得扁平的重要原因。
5835
+ * 这里按宿主线上按钮的规则自绘:primary = button-primary-fill + label-primary-foreground,
5836
+ * outline = 0.5px border-l3 + 透明底,尺寸照 sm(高 28 / 12px 字 / 圆角 8)。
5837
+ */
5940
5838
  function Button(props) {
5941
5839
  if (typeof P.Button === 'function') return h(P.Button, props);
5942
- return h('button', { type: 'button', className: 'dshmn-link', onClick: props.onClick, disabled: props.disabled }, props.children);
5840
+ return h('button', {
5841
+ type: 'button',
5842
+ className: 'dshmn-btn',
5843
+ 'data-variant': props.variant ?? 'outline',
5844
+ disabled: props.disabled,
5845
+ title: props.title,
5846
+ onClick: props.onClick,
5847
+ }, props.children);
5943
5848
  }
5944
5849
 
5945
5850
  /** 输入框(宿主原语自带 0.5px 发丝描边与 focus-within 主题色)。 */
@@ -5971,102 +5876,55 @@ window.__ModuleLoader__.load({
5971
5876
  }, children);
5972
5877
  }
5973
5878
 
5974
- /**
5975
- * 折叠区。优先用宿主原语,取不到再退化为本地实现。
5976
- * @param {object} props 折叠参数
5977
- * @returns {object} React 元素
5978
- */
5979
- function Disclosure(props) {
5980
- const { title, open, onToggle, children } = props;
5981
- if (typeof P.DisclosureRow === 'function') {
5982
- return h('div', { className: 'dshmn-disclose' }, h(P.DisclosureRow, {
5983
- title,
5984
- open: open === true,
5985
- expandable: true,
5986
- expandOnRowClick: true,
5987
- onToggle,
5988
- }, children));
5989
- }
5990
- const head = h('button', {
5991
- type: 'button',
5992
- className: 'dshmn-link',
5993
- 'aria-expanded': open === true,
5994
- onClick: onToggle,
5995
- }, `${open === true ? '▾' : '▸'} ${title}`);
5996
- const body = open === true ? h('div', { className: 'dshmn-discloseBody' }, children) : null;
5997
- return h('div', { className: 'dshmn-disclose' }, head, body);
5998
- }
5879
+ // 折叠区(「更多」「还有 3 种情况」)统一用下面这个 foldHead 拼在页内:
5880
+ // 开合状态留在 MailNotifyPage 自己的 useState 里,于是「收起时标题带当前值」与
5881
+ // 「开合状态可断言」两件事同时成立;旧的 Disclosure 包装随之删除。
5999
5882
 
6000
5883
  // ---------------------------------------------------------------------------
6001
5884
  // 页面
6002
5885
  // ---------------------------------------------------------------------------
6003
5886
 
6004
5887
  /**
6005
- * 一个分组:段头(名字 + 这一段的结论)+ 一条发丝线,内容靠行与行之间的分隔线组织。
5888
+ * 一张卡片(宿主 models / agent-preset / account 三页同款):
5889
+ * .5px 描边 + radius 20 + padding 14 16 + 内部 gap 12;标题 14/22·500,右侧可带一条 12/18 状态读数。
6006
5890
  *
6007
- * **不编号、不套卡片。** 以前是 `1 收件人 / 2 怎么发 / 3 什么时候发给我` 三张同款灰卡,
6008
- * 编号把并列的三个设置组伪装成步骤序列(内容并不是步骤),三块同款灰又让主次消失。
6009
- * @param {object} props 分组参数
5891
+ * **不编号。** 三张卡片是并列的三个设置组,编号会把并列伪装成步骤序列(内容并不是步骤)。
5892
+ * **不靠底色。** 卡片底色等于面板底色(浅色主题下所有 bg-layer 都是纯白),
5893
+ * 卡片只靠描边成立——这也正是宿主 settings 卡片的样子。
5894
+ * 每张卡片另带一个语义修饰类(dshmn-card-recipients / -transport / -outcomes),
5895
+ * 它们只是给 DOM 一个稳定的抓手(和宿主的 data-* 一个用途),样式仍由 .dshmn-card 一份承担。
5896
+ * @param {object} props 卡片参数(kind 是修饰类后缀)
6010
5897
  * @returns {object} React 元素
6011
5898
  */
6012
- function Group(props) {
6013
- const { title, state, children } = props;
6014
- return h('section', { className: 'dshmn-group', 'aria-label': title },
6015
- h('div', { className: 'dshmn-groupHead' },
6016
- h('h3', { className: 'dshmn-groupTitle' }, title),
6017
- state === undefined ? null : h('span', { className: 'dshmn-groupState' }, state)),
6018
- h('div', { className: 'dshmn-groupBody' }, children));
5899
+ function Card(props) {
5900
+ const { title, state, kind, children } = props;
5901
+ const className = kind === undefined ? 'dshmn-card' : `dshmn-card dshmn-card-${kind}`;
5902
+ return h('section', { className, 'aria-label': title },
5903
+ h('div', { className: 'dshmn-cardHead' },
5904
+ h('h3', { className: 'dshmn-cardTitle' }, title),
5905
+ state === undefined ? null : h('span', { className: 'dshmn-cardState' }, state)),
5906
+ h('div', { className: 'dshmn-cardBody' }, children));
6019
5907
  }
6020
5908
 
6021
5909
  /**
6022
- * 一行:标题 + 说明在左,控件在右。
6023
- * @param {object} props 行参数
6024
- * @returns {object} React 元素
6025
- */
6026
- function Row(props) {
6027
- const { title, hint, control, htmlFor, children } = props;
6028
- return h('div', { className: 'dshmn-item' },
6029
- h('div', { className: 'dshmn-itemHead' },
6030
- h('div', { className: 'dshmn-itemText' },
6031
- htmlFor === undefined
6032
- ? h('div', { className: 'dshmn-itemTitle' }, title)
6033
- : h('label', { className: 'dshmn-itemTitle', htmlFor }, title),
6034
- hint === undefined ? null : h('div', { className: 'dshmn-hint' }, hint)),
6035
- control === undefined ? null : h('div', { className: 'dshmn-itemCtrl' }, control)),
6036
- children);
6037
- }
6038
-
6039
- /**
6040
- * 缩进子行:表达"这一项只对上面那一项生效",而不是再补一句说明。
6041
- * 父项关掉时整块压暗,但仍然可见——它没被删掉,只是暂时不参与。
6042
- * @param {object} props 子行参数
5910
+ * 折叠标题:一行原生按钮(点击开合,键盘 tab 得到),标题自带 ▸/▾ 表示开合状态。
5911
+ *
5912
+ * 不用宿主原语 DisclosureRow:它把标题包进自己的结构里,而这一轮要求
5913
+ * 「收起时把当前值写在标题上」,也需要单测能直接看见开合状态。
5914
+ * @param {string} id 受控内容区的 id(aria-controls)
5915
+ * @param {string} label 标题文字(已含当前值)
5916
+ * @param {boolean} open 是否展开
5917
+ * @param {Function} onToggle 点击开合
6043
5918
  * @returns {object} React 元素
6044
5919
  */
6045
- function Sub(props) {
6046
- const { title, hint, control, muted } = props;
6047
- return h('div', { className: 'dshmn-sub', 'data-muted': muted === true ? 'true' : 'false' },
6048
- h('div', { className: 'dshmn-itemHead' },
6049
- h('div', { className: 'dshmn-itemText' },
6050
- h('div', { className: 'dshmn-itemTitle' }, title),
6051
- hint === undefined ? null : h('div', { className: 'dshmn-hint' }, hint)),
6052
- control === undefined ? null : h('div', { className: 'dshmn-itemCtrl' }, control)));
6053
- }
6054
-
6055
- /** 带单位后缀的数字输入(贴在一行里,所以宽度定死;单位常驻可见)。 */
6056
- function NumberField({ id, value, unit, onChange, onBlur, placeholder, label }) {
6057
- return h('span', { className: 'dshmn-num' },
6058
- h('span', { className: 'dshmn-numBox' },
6059
- h(TextInput, {
6060
- id,
6061
- type: 'number',
6062
- inputMode: 'numeric',
6063
- value: value ?? '',
6064
- placeholder,
6065
- 'aria-label': label,
6066
- onChange: (event) => onChange(event.target.value === '' ? '' : Number(event.target.value)),
6067
- onBlur,
6068
- })),
6069
- h('span', { className: 'dshmn-unit' }, unit));
5920
+ function foldHead(id, label, open, onToggle) {
5921
+ return h('button', {
5922
+ type: 'button',
5923
+ className: 'dshmn-fold',
5924
+ 'aria-expanded': open === true ? 'true' : 'false',
5925
+ 'aria-controls': id,
5926
+ onClick: onToggle,
5927
+ }, (open === true ? '▾ ' : '▸ ') + label);
6070
5928
  }
6071
5929
 
6072
5930
  /**
@@ -6078,9 +5936,15 @@ window.__ModuleLoader__.load({
6078
5936
  const { remotes } = props;
6079
5937
  const t = (key, params) => i18n.t(key, params);
6080
5938
 
6081
- const [ready, setReady] = React.useState(remotes.current !== null);
5939
+ /** settings 远程真的可用了吗。ready 只认它——rpc / credentials 先到都不算。 */
5940
+ const settingsAvailable = () => typeof remotes.current?.settings?.describe === 'function';
5941
+ // 就绪的判定必须收到 settings 上:只看 remotes.current 非空的话,connection 先注入就会把 ready
5942
+ // 烧成 true,而 load() 当时读不到 settings、直接返回——之后 settings 真的到了也没人再叫它,
5943
+ // 这一页就永远停在空态(按钮全禁用,连读失败都不报)。
5944
+ const [ready, setReady] = React.useState(() => settingsAvailable());
6082
5945
  const [attempt, setAttempt] = React.useState(0);
6083
5946
  const [draft, setDraft] = React.useState({});
5947
+ const [baseline, setBaseline] = React.useState({});
6084
5948
  const [save, setSave] = React.useState({ phase: 'idle', at: 0, error: '' });
6085
5949
  const [now, setNow] = React.useState(() => Date.now());
6086
5950
  const [credentialState, setCredentialState] = React.useState({});
@@ -6088,26 +5952,20 @@ window.__ModuleLoader__.load({
6088
5952
  const [credCheck, setCredCheck] = React.useState(0);
6089
5953
  const [pwDraft, setPwDraft] = React.useState({});
6090
5954
  const [pwClear, setPwClear] = React.useState({});
5955
+ const [pwEdit, setPwEdit] = React.useState({});
6091
5956
  const [resetting, setResetting] = React.useState(false);
6092
5957
  const [confirmReset, setConfirmReset] = React.useState(false);
6093
5958
  const [helpKind, setHelpKind] = React.useState(null);
6094
- const [test, setTest] = React.useState({ phase: 'idle', transport: '', ms: 0, error: '' });
6095
- const [lastDelivery, setLastDelivery] = React.useState(null);
6096
- const [capability, setCapability] = React.useState('unknown');
5959
+ const [test, setTest] = React.useState({ phase: 'idle', ms: 0, error: '' });
6097
5960
  const [showMoreOutcomes, setShowMoreOutcomes] = React.useState(false);
6098
- const [showSender, setShowSender] = React.useState(false);
6099
- /** 发件人弹层的编辑态:null = 关闭;{ index, name, address },index === 清单长度 表示新增。 */
6100
- const [senderEdit, setSenderEdit] = React.useState(null);
6101
- /** 发件人弹层的就地报错文案(空串 = 没报错)。 */
6102
- const [senderErr, setSenderErr] = React.useState('');
6103
- const [showAdvanced, setShowAdvanced] = React.useState(false);
6104
- const [customHost, setCustomHost] = React.useState(false);
5961
+ /** 端口 / 加密收在「更多」里,默认收起(偏离默认值时标题会带上现值)。 */
5962
+ const [moreOpen, setMoreOpen] = React.useState(false);
5963
+ /** 每个事件开关行自己的说明开合:点问号切一行,互不影响。 */
5964
+ const [openHints, setOpenHints] = React.useState({});
6105
5965
 
6106
5966
  const draftRef = React.useRef({});
6107
5967
  const baselineRef = React.useRef({});
6108
5968
  const viewRef = React.useRef(null);
6109
- const timers = React.useRef({});
6110
- const pwTimers = React.useRef({});
6111
5969
  // 语言切换时强制重渲染(locale 服务只在变化时通知,不驱动 React)
6112
5970
  const [, setLocaleRev] = React.useState(0);
6113
5971
 
@@ -6117,33 +5975,62 @@ window.__ModuleLoader__.load({
6117
5975
  return locale.subscribe(() => setLocaleRev((n) => n + 1));
6118
5976
  }, []);
6119
5977
 
6120
- // 远程命名空间迟到:最多轮询 40 次(约 6 秒)
5978
+ // 远程命名空间迟到:最多轮询 40 次(约 6 秒)。只认 settings,别的服务晚到不算就绪。
6121
5979
  React.useEffect(() => {
6122
5980
  if (ready || attempt >= 40) return undefined;
6123
5981
  const timer = setTimeout(() => {
6124
- if (remotes.current !== null) setReady(true);
5982
+ if (settingsAvailable()) setReady(true);
6125
5983
  setAttempt((n) => n + 1);
6126
5984
  }, 150);
6127
5985
  return () => clearTimeout(timer);
6128
- }, [ready, attempt, remotes]);
5986
+ }, [ready, attempt]);
6129
5987
 
6130
- const load = React.useCallback(async () => {
5988
+ /**
5989
+ * 读一次配置,并按 **配置指纹** 认领本插件的那一项。
5990
+ *
5991
+ * 认领规则:ns 是 profile 入口 id(换 profile 就变),客户端不许写死。别拿 describe() 里的
5992
+ * schema 当指纹——它是 schema.toJSON() 的快照(只有 uid/refs,没有 dict),按 dict 找永远
5993
+ * 找不到。稳定指纹在 value 里:只有本插件同时声明 smtpHost 与 smtpPasswordRef。
5994
+ *
5995
+ * @param {{ preserveDraft?: boolean }} [options] preserveDraft = 冲突恢复模式:
5996
+ * 用户的草稿**一个字都不动**(那是他刚填、还没写进去的东西),只把基线对齐服务端最新值、
5997
+ * revision 换成新的(viewRef.current 就是 revision 的持有者),下一次保存才不会再撞同一个值。
5998
+ * @returns {Promise<void>}
5999
+ */
6000
+ const load = React.useCallback(async (options) => {
6131
6001
  const bridge = remotes.current;
6002
+ const preserveDraft = options?.preserveDraft === true;
6132
6003
  if (bridge === null) return;
6004
+ // settings 还没注入 ≠ 读不到配置:它只是还没到。这一档不落成 loadFailed,
6005
+ // 免得把一次正常的迟到渲染成红色报错(ready 会为它再放行一次装载)。
6006
+ if (typeof bridge?.settings?.describe !== 'function') {
6007
+ setSave({ phase: 'unserved', at: 0, error: '' });
6008
+ return;
6009
+ }
6133
6010
  try {
6134
6011
  const described = unwrap(await bridge.settings.describe());
6135
6012
  const namespaces = Array.isArray(described?.namespaces) ? described.namespaces : [];
6136
- const found = namespaces.find((item) => item && item.ns === NS) ?? null;
6013
+ const found = namespaces.find((d) => d && d.value && typeof d.value === 'object'
6014
+ && Object.hasOwn(d.value, 'smtpHost') && Object.hasOwn(d.value, 'smtpPasswordRef')) ?? null;
6137
6015
  viewRef.current = found;
6138
6016
  const value = found && found.value && typeof found.value === 'object' ? { ...found.value } : {};
6139
- draftRef.current = value;
6140
- baselineRef.current = value;
6141
- setDraft(value);
6017
+ baselineRef.current = { ...value };
6018
+ setBaseline({ ...value });
6019
+ if (preserveDraft) {
6020
+ // 冲突恢复:草稿留着,只把基线与 revision 抬到最新——这样再过一次保存就是干净的写。
6021
+ setSave(found === null
6022
+ ? { phase: 'unserved', at: 0, error: '' }
6023
+ : { phase: 'conflictReloaded', at: 0, error: '' });
6024
+ return;
6025
+ }
6026
+ draftRef.current = { ...value };
6027
+ setDraft({ ...value });
6142
6028
  setSave(found === null
6143
- ? { phase: 'error', at: 0, error: t('status.notServed') }
6029
+ ? { phase: 'unserved', at: 0, error: '' }
6144
6030
  : { phase: 'idle', at: 0, error: '' });
6145
6031
  } catch (err) {
6146
- setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6032
+ // 读失败是**独立的一档**:它不是「保存失败」,重试也不该去调 saveAll()。
6033
+ setSave({ phase: 'loadFailed', at: 0, error: String(err?.message ?? err) });
6147
6034
  }
6148
6035
  }, [remotes]);
6149
6036
 
@@ -6169,43 +6056,27 @@ window.__ModuleLoader__.load({
6169
6056
  }
6170
6057
  }, [remotes]);
6171
6058
 
6172
- /** 最近一次投递结果 + Host 能力探测。 */
6173
- const refreshDelivery = React.useCallback(async () => {
6174
- // 顺带探一次能力:typert 清单是**服务器启动时**扫描注册的,
6175
- // 所以"connection 在"不等于"mailNotify 端点在"。探不到就老实说不可用,
6176
- // 而不是给一个点下去必然报错的按钮。
6177
- if (remotes.current?.rpc === undefined) {
6178
- setCapability('missing');
6179
- return;
6180
- }
6181
- try {
6182
- const state = unwrap(await callRemote('state'));
6183
- setCapability('ready');
6184
- setLastDelivery(state?.recent?.[0] ?? null);
6185
- } catch (err) {
6186
- setCapability('missing');
6187
- console.warn('[dsh-notify-p] Host 未提供 mailNotify 端点(重启 DSH 后生效)/ endpoint unavailable:', err);
6188
- }
6189
- }, [remotes]);
6190
-
6191
- // 首次就绪后装载
6059
+ // 首次就绪后装载。读失败 / 认领不到之后**不再自动重试**:
6060
+ // 那两档各自有出路(「重新读取」按钮 / 换个 profile 再看),自动重试只会把读数一直抖回别处,
6061
+ // 也会让「重新读取」到底点了没点变得看不出来。
6192
6062
  React.useEffect(() => {
6193
- if (ready && viewRef.current === null) void load();
6194
- }, [ready, load, attempt]);
6063
+ if (!ready || viewRef.current !== null) return;
6064
+ if (save.phase === 'loadFailed' || save.phase === 'unserved') return;
6065
+ void load();
6066
+ }, [ready, load, attempt, save.phase]);
6195
6067
 
6196
6068
  // `viewRef.current` 是 load() 里赋的**引用**,赋值不会触发重渲染。
6197
6069
  // 所以依赖数组里写 viewRef.current 是无效的:首次挂载时它还是 null,
6198
6070
  // load() 完成后依赖没变,这个 effect 永远不会再跑一遍 —— 结果就是
6199
- // 凭据永远是「还没保存」、投递账本永远读不到、测试按钮永远不出现。
6071
+ // 凭据永远是「还没保存」、测试按钮永远不出现。
6200
6072
  // 在渲染期把它取成一个普通值参与依赖,false→true 那一次就会把下面几条读都带起来。
6201
6073
  const loaded = viewRef.current !== null;
6202
6074
 
6203
6075
  React.useEffect(() => {
6204
6076
  if (ready && loaded) {
6205
6077
  void refreshCredentials([viewRef.current?.value?.smtpPasswordRef]);
6206
- void refreshDelivery();
6207
6078
  }
6208
- }, [ready, loaded, refreshCredentials, refreshDelivery, attempt]);
6079
+ }, [ready, loaded, refreshCredentials, attempt]);
6209
6080
 
6210
6081
  // 「已保存 · N 秒前」需要一个会走的钟;只在保存过之后跑,离开保存态就停
6211
6082
  React.useEffect(() => {
@@ -6244,161 +6115,209 @@ window.__ModuleLoader__.load({
6244
6115
  }, [refreshCredentials]);
6245
6116
 
6246
6117
  /**
6247
- * 把若干字段写回 Host(即时生效的核心)。
6248
- * @param {string[]} keys 要提交的字段名
6249
- * @returns {Promise<void>}
6250
- */
6251
- const commit = React.useCallback(async (keys) => {
6252
- const bridge = remotes.current;
6253
- const view = viewRef.current;
6254
- if (bridge === null || view === null) return;
6255
- const base = baselineRef.current ?? {};
6256
- const ops = [];
6257
- for (const key of keys) {
6258
- const value = draftRef.current[key];
6259
- if (value === base[key]) continue;
6260
- if (value === '' || value === null || value === undefined) ops.push({ op: 'unset', path: [key] });
6261
- else ops.push({ op: 'set', path: [key], value });
6262
- }
6263
- if (ops.length === 0) return;
6264
- setSave({ phase: 'saving', at: 0, error: '' });
6265
- try {
6266
- const next = unwrap(await bridge.settings.mutate(NS, ops, view.revision));
6267
- if (next && typeof next === 'object') {
6268
- viewRef.current = next;
6269
- baselineRef.current = next.value && typeof next.value === 'object' ? { ...next.value } : {};
6270
- }
6271
- setSave({ phase: 'saved', at: Date.now(), error: '' });
6272
- } catch (err) {
6273
- setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6274
- // 冲突或失败都重读一次,避免本地草稿与 Host 漂移
6275
- await load();
6276
- }
6277
- }, [remotes, load]);
6278
-
6279
- /**
6280
- * 改一个字段。
6118
+ * 改一个字段:**只改本地草稿**,不碰 Host。写回只发生在点「保存」的时候。
6281
6119
  * @param {string} key 字段名
6282
6120
  * @param {unknown} value 新值
6283
- * @param {{ immediate?: boolean, delay?: number }} [opts] 提交策略
6284
6121
  * @returns {void}
6285
6122
  */
6286
- const setField = React.useCallback((key, value, opts = {}) => {
6123
+ const setField = React.useCallback((key, value) => {
6287
6124
  draftRef.current = { ...draftRef.current, [key]: value };
6288
6125
  setDraft(draftRef.current);
6289
- clearTimeout(timers.current[key]);
6290
- if (opts.immediate === true) {
6291
- void commit([key]);
6292
- return;
6293
- }
6294
- timers.current[key] = setTimeout(() => void commit([key]), opts.delay ?? TEXT_DEBOUNCE_MS);
6295
- }, [commit]);
6296
-
6297
- /** 立刻提交某字段(失焦兜底)。 */
6298
- const flush = React.useCallback((key) => {
6299
- clearTimeout(timers.current[key]);
6300
- void commit([key]);
6301
- }, [commit]);
6126
+ }, []);
6302
6127
 
6303
6128
  /**
6304
- * 一次改多个字段并**合成一次提交**。
6305
- *
6306
- * 必须批量:`commit` 带的是同一个 revision,连续发多个即时写入会互相冲突,
6307
- * 后发的会被 Host 拒绝并被随后的 load() 回滚——表面看就是"预设填了一半"。
6129
+ * 一次改多个字段,合成同一份草稿(邮箱服务商预设用)。
6130
+ * 保存是一次 mutate,所以这里不需要按字段拆开写。
6308
6131
  * @param {object} patch 字段名到新值的映射
6309
6132
  * @returns {void}
6310
6133
  */
6311
6134
  const setFields = React.useCallback((patch) => {
6312
6135
  draftRef.current = { ...draftRef.current, ...patch };
6313
6136
  setDraft(draftRef.current);
6314
- for (const key of Object.keys(patch)) clearTimeout(timers.current[key]);
6315
- void commit(Object.keys(patch));
6316
- }, [commit]);
6317
-
6318
- /** 当前生效的发件人清单(含老配置迁移)。纯函数很便宜,不必 memo。 */
6319
- const senders = migrateSenders(draft);
6320
- /** 当前生效的地址。 */
6321
- const senderAddr = activeSenderAddress(draft, senders);
6137
+ }, []);
6322
6138
 
6323
6139
  /**
6324
- * 写发件人清单。
6325
- *
6326
- * **必须同时清掉旧的 senderName / fromAddress**:清单为空时 Host 会回落到旧字段,
6327
- * 不清就会"刚删掉的那条又回来了"。
6328
- * 用 setFields 合成一次提交,两个字段共用同一个 revision,不会互相冲突。
6329
- * @param {{name: string, address: string}[]} list 新清单
6330
- * @param {string} active 新的生效地址('' 表示清单为空)
6331
- * @returns {void}
6140
+ * 还挂在草稿里的凭据引用名。引用名由凭据行自己给出(硬约束 4),不从别处猜。
6141
+ * @returns {string[]} 需要写进凭据库的引用名
6332
6142
  */
6333
- const writeSenders = React.useCallback((list, active) => {
6334
- setFields({ senders: list, activeSender: active, senderName: '', fromAddress: '' });
6335
- }, [setFields]);
6336
-
6337
- /** 打开发件人弹层:index 传清单长度即"新增"。 */
6338
- const openSenderDialog = React.useCallback((index) => {
6339
- const entry = index < senders.length ? senders[index] : { name: '', address: '' };
6340
- setSenderEdit({ index, name: entry.name, address: entry.address });
6341
- setSenderErr('');
6342
- }, [senders]);
6343
-
6344
- /** 关闭发件人弹层。 */
6345
- const closeSenderDialog = React.useCallback(() => {
6346
- setSenderEdit(null);
6347
- setSenderErr('');
6348
- }, []);
6349
-
6350
- /** 保存发件人弹层里的内容;不合法就地报错、不写入。 */
6351
- const saveSenderDialog = React.useCallback(() => {
6352
- const issue = senderIssue(senders, senderEdit.index, senderEdit);
6353
- if (issue !== '') {
6354
- setSenderErr(t(`sender.issue.${issue}`));
6355
- return;
6143
+ const pendingCredentialRefs = React.useCallback(() => {
6144
+ const refs = new Set();
6145
+ for (const [ref, value] of Object.entries(pwDraft)) {
6146
+ if (String(value ?? '').trim() !== '') refs.add(ref);
6356
6147
  }
6357
- const next = senderCommit({ list: senders, active: senderAddr, editing: senderEdit.index }, senderEdit);
6358
- writeSenders(next.list, next.active);
6359
- setSenderEdit(null);
6360
- setSenderErr('');
6361
- }, [senders, senderEdit, senderAddr, writeSenders, t]);
6362
-
6363
- /** 选中某条发件人。 */
6364
- const pickSender = React.useCallback((address) => {
6365
- writeSenders(senders, address);
6366
- }, [senders, writeSenders]);
6367
-
6368
- /** 删掉某条发件人。 */
6369
- const removeSender = React.useCallback((index) => {
6370
- const next = senderRemove({ list: senders, active: senderAddr }, index);
6371
- writeSenders(next.list, next.active);
6372
- }, [senders, senderAddr, writeSenders]);
6148
+ for (const [ref, flag] of Object.entries(pwClear)) {
6149
+ if (flag === true) refs.add(ref);
6150
+ }
6151
+ return [...refs].filter((ref) => String(ref ?? '').trim() !== '');
6152
+ }, [pwDraft, pwClear]);
6373
6153
 
6374
6154
  /**
6375
- * 写凭据。**目标引用名由调用方给定**(硬约束 4),绝不从草稿里猜。
6155
+ * 写一条凭据。**目标引用名由调用方给定**(硬约束 4),绝不从草稿里猜。
6156
+ * 只有 saveAll 会调它:保存是唯一写凭据库的时机;失败就抛出,由调用方转成「保存失败」。
6376
6157
  * @param {string} ref 凭据引用名
6377
6158
  * @returns {Promise<void>}
6378
6159
  */
6379
6160
  const commitCredential = React.useCallback(async (ref) => {
6380
6161
  const bridge = remotes.current;
6381
- const view = viewRef.current;
6382
- if (bridge === null || view === null) return;
6162
+ if (bridge === null) return;
6383
6163
  const name = String(ref ?? '').trim();
6384
6164
  if (name === '') return;
6385
- const value = pwDraft[name] ?? '';
6165
+ const value = String(pwDraft[name] ?? '');
6386
6166
  const clearing = pwClear[name] === true;
6387
- if (!clearing && value === '') return;
6167
+ if (!clearing && value.trim() === '') return;
6168
+ if (clearing) unwrap(await bridge.credentials.unset(name));
6169
+ else unwrap(await bridge.credentials.set(name, value));
6170
+ setPwDraft((prev) => ({ ...prev, [name]: '' }));
6171
+ setPwClear((prev) => ({ ...prev, [name]: false }));
6172
+ await refreshCredentials([name]);
6173
+ }, [remotes, pwDraft, pwClear, refreshCredentials]);
6174
+
6175
+ /**
6176
+ * 保存:把草稿与基线的差异一次性写回。
6177
+ *
6178
+ * 顺序与原子性(这是这一页唯一的写入口):
6179
+ * 1. 配置字段合成**一次** mutate(found.ns, ops, revision);
6180
+ * 2. 授权码草稿与已存值不同时,再把它写进凭据库;
6181
+ * 3. 两步都成功才进入「已保存」,并把 revision 换成 mutate 返回的新值。
6182
+ *
6183
+ * 三种失败各有各的出路,**都不清空草稿**(用户填的东西留在原地):
6184
+ * - revision 冲突(settings/conflict):自动重读,草稿不动、revision 换新,让用户再点一次;
6185
+ * - 只写了半截:分开报「哪一半成功、哪一半没成」,并只给失败那一半的重试按钮;
6186
+ * - 两半都没成:退回「保存失败:原因」+ 重试(凭据那边的原因一并带上)。
6187
+ * @returns {Promise<void>}
6188
+ */
6189
+ const saveAll = React.useCallback(async () => {
6190
+ const bridge = remotes.current;
6191
+ const view = viewRef.current;
6192
+ if (bridge === null || view === null) return;
6193
+ const ops = diffOps(draftRef.current, baselineRef.current);
6194
+ const credRefs = pendingCredentialRefs();
6195
+ if (ops.length === 0 && credRefs.length === 0) {
6196
+ setSave({ phase: 'idle', at: 0, error: '' });
6197
+ return;
6198
+ }
6199
+ setSave({ phase: 'saving', at: 0, error: '' });
6200
+ // 两半各自记账。一句「保存失败」会把已经成功的那一半也抹掉,用户根本不知道该重试哪边,
6201
+ // 于是他会把两边都重做一遍——凭据那半还可能是清空再重填。所以这里分开记、分开报。
6202
+ let configError = '';
6203
+ let credError = '';
6204
+ if (ops.length > 0) {
6205
+ try {
6206
+ const next = unwrap(await bridge.settings.mutate(view.ns, ops, view.revision));
6207
+ const after = next && typeof next === 'object' ? next : view;
6208
+ // ns 一律以这次返回的为准:认领来的 ns 不能被任何写死的东西盖回去。
6209
+ viewRef.current = {
6210
+ ...after,
6211
+ ns: typeof after.ns === 'string' && after.ns !== '' ? after.ns : view.ns,
6212
+ };
6213
+ // 配置这一段已经真的落盘,基线抬到刚发出去的那份草稿;
6214
+ // 万一接下来凭据写失败,脏读数就只剩那一条凭据。
6215
+ baselineRef.current = { ...draftRef.current };
6216
+ setBaseline({ ...draftRef.current });
6217
+ } catch (err) {
6218
+ if (isConflict(err)) {
6219
+ // 别人先改了:**自动重读**(草稿原样保留、revision 换成新的),再让用户点一次保存。
6220
+ // 不重读的话重试会拿着旧 revision 再撞一次,这条路是死的。
6221
+ await load({ preserveDraft: true });
6222
+ return;
6223
+ }
6224
+ configError = String(err?.message ?? err);
6225
+ }
6226
+ }
6227
+ // 配置那半失败也照旧写凭据:两半互不依赖,一次点击把能落盘的那半落盘。
6228
+ for (const ref of credRefs) {
6229
+ try {
6230
+ await commitCredential(ref);
6231
+ } catch (err) {
6232
+ credError = String(err?.message ?? err);
6233
+ break;
6234
+ }
6235
+ }
6236
+ const credWrote = credRefs.length > 0 && credError === '';
6237
+ if (configError === '' && credError === '') {
6238
+ setSave({ phase: 'saved', at: Date.now(), error: '' });
6239
+ return;
6240
+ }
6241
+ if (configError === '') {
6242
+ // 配置成功、凭据失败:只该重试凭据。
6243
+ setSave({ phase: 'partialCred', at: 0, error: credError, credError: '' });
6244
+ return;
6245
+ }
6246
+ if (credWrote) {
6247
+ // 凭据成功、配置失败:只该重试配置。
6248
+ setSave({ phase: 'partialConfig', at: 0, error: configError, credError: '' });
6249
+ return;
6250
+ }
6251
+ // 两半都没成(或压根没凭据可写):退回到原来的「保存失败」一档,但把凭据的原因也带上。
6252
+ setSave({ phase: 'error', at: 0, error: configError, credError });
6253
+ }, [remotes, pendingCredentialRefs, commitCredential, load]);
6254
+
6255
+ /** 只重试授权码(配置那一半已经写成功了)。 */
6256
+ const retryCredentials = React.useCallback(async () => {
6257
+ const credRefs = pendingCredentialRefs();
6258
+ if (credRefs.length === 0) {
6259
+ setSave({ phase: 'idle', at: 0, error: '' });
6260
+ return;
6261
+ }
6388
6262
  setSave({ phase: 'saving', at: 0, error: '' });
6389
6263
  try {
6390
- if (clearing) unwrap(await bridge.credentials.unset(name));
6391
- else unwrap(await bridge.credentials.set(name, value));
6392
- setPwDraft((prev) => ({ ...prev, [name]: '' }));
6393
- setPwClear((prev) => ({ ...prev, [name]: false }));
6264
+ for (const ref of credRefs) await commitCredential(ref);
6394
6265
  setSave({ phase: 'saved', at: Date.now(), error: '' });
6395
- await refreshCredentials([name, draftRef.current?.smtpPasswordRef]);
6396
6266
  } catch (err) {
6397
- setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6267
+ setSave({ phase: 'partialCred', at: 0, error: String(err?.message ?? err), credError: '' });
6398
6268
  }
6399
- }, [remotes, pwDraft, pwClear, refreshCredentials]);
6269
+ }, [pendingCredentialRefs, commitCredential]);
6270
+
6271
+ /** 只重试配置(授权码那一半已经写成功了)。 */
6272
+ const retryConfig = React.useCallback(async () => {
6273
+ const bridge = remotes.current;
6274
+ const view = viewRef.current;
6275
+ if (bridge === null || view === null) return;
6276
+ const ops = diffOps(draftRef.current, baselineRef.current);
6277
+ if (ops.length === 0) {
6278
+ setSave({ phase: 'idle', at: 0, error: '' });
6279
+ return;
6280
+ }
6281
+ setSave({ phase: 'saving', at: 0, error: '' });
6282
+ try {
6283
+ const next = unwrap(await bridge.settings.mutate(view.ns, ops, view.revision));
6284
+ const after = next && typeof next === 'object' ? next : view;
6285
+ viewRef.current = {
6286
+ ...after,
6287
+ ns: typeof after.ns === 'string' && after.ns !== '' ? after.ns : view.ns,
6288
+ };
6289
+ baselineRef.current = { ...draftRef.current };
6290
+ setBaseline({ ...draftRef.current });
6291
+ setSave({ phase: 'saved', at: Date.now(), error: '' });
6292
+ } catch (err) {
6293
+ if (isConflict(err)) {
6294
+ await load({ preserveDraft: true });
6295
+ return;
6296
+ }
6297
+ setSave({ phase: 'partialConfig', at: 0, error: String(err?.message ?? err), credError: '' });
6298
+ }
6299
+ }, [remotes, load]);
6400
6300
 
6401
- /** 恢复默认:清掉本命名空间在 settings.yaml 里的全部覆盖。 */
6301
+ /**
6302
+ * 放弃改动:草稿整体回滚到基线,授权码草稿一并清掉,读数回到「没有未保存的改动」。
6303
+ * @returns {void}
6304
+ */
6305
+ const discardAll = React.useCallback(() => {
6306
+ const base = baselineRef.current ?? {};
6307
+ draftRef.current = { ...base };
6308
+ setDraft({ ...base });
6309
+ setPwDraft({});
6310
+ setPwClear({});
6311
+ setPwEdit({});
6312
+ setSave({ phase: 'idle', at: 0, error: '' });
6313
+ }, []);
6314
+
6315
+ /**
6316
+ * 恢复全部默认:清掉本命名空间在 profile 里的全部覆盖,**立即生效**。
6317
+ *
6318
+ * 它是破坏性操作,所以照旧先弹确认;确认之后不再受「保存」按钮约束——
6319
+ * 语义就是「把服务端的覆盖清空」,草稿随后按服务端的新值重建。
6320
+ */
6402
6321
  const resetAll = React.useCallback(async () => {
6403
6322
  const bridge = remotes.current;
6404
6323
  const view = viewRef.current;
@@ -6406,65 +6325,75 @@ window.__ModuleLoader__.load({
6406
6325
  setResetting(true);
6407
6326
  setSave({ phase: 'saving', at: 0, error: '' });
6408
6327
  try {
6409
- const next = unwrap(await bridge.settings.replace(NS, {}, view.revision));
6410
- if (next && typeof next === 'object') viewRef.current = next;
6328
+ const next = unwrap(await bridge.settings.replace(view.ns, {}, view.revision));
6329
+ if (next && typeof next === 'object') {
6330
+ viewRef.current = {
6331
+ ...next,
6332
+ ns: typeof next.ns === 'string' && next.ns !== '' ? next.ns : view.ns,
6333
+ };
6334
+ }
6335
+ // 服务端覆盖已清空:**凭据草稿一并丢掉**。留着它在草稿里,会立刻又算出一项
6336
+ // 「有 1 项改动未保存」,而那一项其实属于刚被重置掉的旧配置(授权码本身不受重置影响)。
6337
+ setPwDraft({});
6338
+ setPwClear({});
6339
+ setPwEdit({});
6411
6340
  await load();
6412
6341
  setSave({ phase: 'saved', at: Date.now(), error: '' });
6413
6342
  } catch (err) {
6343
+ // 失败不重读:重读会把用户还没保存的草稿冲掉。
6414
6344
  setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6415
- await load();
6416
6345
  } finally {
6417
6346
  setResetting(false);
6418
6347
  setConfirmReset(false);
6419
6348
  }
6420
6349
  }, [remotes, load]);
6421
6350
 
6422
- /** 发一封测试邮件。Host 没提供该能力时不显示按钮。 */
6351
+ /**
6352
+ * 发一封测试邮件。
6353
+ *
6354
+ * 结果是 RPC **当场**返回的:成败与原始报错直接进 test 态,
6355
+ * 因为账本删掉之后,这条路径就是唯一的「真投递过没有」的证据。
6356
+ */
6423
6357
  const sendTest = React.useCallback(async () => {
6424
- if (capability !== 'ready') return;
6425
- setTest({ phase: 'sending', transport: '', ms: 0, error: '' });
6358
+ if (typeof remotes.current?.rpc?.call !== 'function') return;
6359
+ setTest({ phase: 'sending', ms: 0, error: '' });
6426
6360
  try {
6427
6361
  const result = unwrap(await callRemote('test'));
6428
6362
  if (result && result.ok === false) {
6429
- setTest({ phase: 'fail', transport: String(result.transport ?? ''), ms: 0, error: String(result.error ?? '') });
6363
+ setTest({ phase: 'fail', ms: 0, error: String(result.error ?? '') });
6430
6364
  } else {
6431
6365
  setTest({
6432
6366
  phase: 'ok',
6433
- transport: String(result?.transport ?? ''),
6434
6367
  ms: Number(result?.ms ?? 0),
6435
6368
  error: '',
6436
6369
  });
6437
6370
  }
6438
- setLastDelivery(result ?? null);
6439
6371
  } catch (err) {
6440
- setTest({ phase: 'fail', transport: '', ms: 0, error: String(err?.message ?? err) });
6372
+ setTest({ phase: 'fail', ms: 0, error: String(err?.message ?? err) });
6441
6373
  }
6442
- }, [remotes, capability]);
6443
-
6444
- // 离开页面时把还在防抖里的改动落盘,避免"改完就走"丢改动
6445
- React.useEffect(() => () => {
6446
- for (const [key, timer] of Object.entries(timers.current)) {
6447
- clearTimeout(timer);
6448
- void commit([key]);
6449
- }
6450
- }, [commit]);
6374
+ }, [remotes]);
6451
6375
 
6452
6376
  // ---- 派生值 ----
6453
6377
  const recipients = parseRecipients(draft.recipients);
6378
+ // 凭据行的写入目标由**草稿**给出(硬约束 4:写入目标跟着这一行走)。
6454
6379
  const smtpPasswordRef = String(draft.smtpPasswordRef ?? '').trim();
6455
6380
  const configured = (ref) => ref !== '' && credentialState[ref] === true;
6381
+ // 判决条一律按**已保存的配置**(baseline)判定。
6382
+ // 拿草稿下结论会当场骗人:草稿填齐就报「可以发信了」,而「发送测试邮件」用的正是已保存
6383
+ // 配置,用户一点就失败——先被夸一句、再被打一巴掌,比什么都不说更糟。
6384
+ const savedRecipients = parseRecipients(baseline.recipients);
6385
+ const savedPasswordRef = String(baseline.smtpPasswordRef ?? '').trim();
6456
6386
  const statusKey = deriveStatus({
6457
- enabled: draft.enabled === true,
6458
- recipientCount: recipients.valid.length,
6459
- host: draft.smtpHost,
6460
- user: draft.smtpUser,
6461
- credentialConfigured: configured(smtpPasswordRef),
6462
- lastDelivery,
6387
+ enabled: baseline.enabled === true,
6388
+ recipientCount: savedRecipients.valid.length,
6389
+ host: baseline.smtpHost,
6390
+ user: baseline.smtpUser,
6391
+ credentialConfigured: configured(savedPasswordRef),
6463
6392
  });
6464
- /** SMTP 这一段自己配好了没有——凭据行在下面很远,段头这一格是它唯一的上层读数。 */
6465
- const transportReady = configured(smtpPasswordRef)
6466
- && String(draft.smtpHost ?? '').trim() !== ''
6467
- && String(draft.smtpUser ?? '').trim() !== '';
6393
+ /** SMTP 这一段自己配好了没有——凭据行在下面很远,段头这一格是它唯一的上层读数(同样按已保存值)。 */
6394
+ const transportReady = configured(savedPasswordRef)
6395
+ && String(baseline.smtpHost ?? '').trim() !== ''
6396
+ && String(baseline.smtpUser ?? '').trim() !== '';
6468
6397
  const transportState = t(transportReady ? 'transportState.done' : 'transportState.todo');
6469
6398
  const STATUS_TITLE_KEY = {
6470
6399
  disabled: 'status.disabled',
@@ -6473,7 +6402,6 @@ window.__ModuleLoader__.load({
6473
6402
  needUser: 'status.needUser',
6474
6403
  needCredential: 'status.needCredential',
6475
6404
  ready: 'status.ready',
6476
- lastFailed: 'status.lastFailed',
6477
6405
  };
6478
6406
  /** 判决句:**全页最大的字号**,也是这一页真正要回答的那句话。 */
6479
6407
  const statusTitle = statusKey === 'needCredential'
@@ -6485,32 +6413,105 @@ window.__ModuleLoader__.load({
6485
6413
  'notifyAbortedByUser', 'notifyInterrupted', 'notifyMaxTokens']
6486
6414
  .filter((key) => draft[key] === true).length;
6487
6415
  const preset = matchPreset(draft.smtpHost);
6488
- const testSupported = capability === 'ready';
6416
+ /** 远程桥接有没有接上;没接上时这一页连草稿都读不到,先不给人按钮。 */
6417
+ const bridgeReady = remotes.current !== null;
6418
+ /** 能不能真发一封:只看 connection 有没有把 RPC 接进来(不再探 Host 端点)。 */
6419
+ const rpcReady = typeof remotes.current?.rpc?.call === 'function';
6489
6420
  const sending = test.phase === 'sending';
6490
6421
  /** 判决条那一档:状态→色调的映射只此一份。 */
6491
6422
  const tone = STATUS_TONE[statusKey] ?? 'idle';
6492
6423
 
6424
+ // ---- 草稿 vs 基线:保存按钮的可用状态与整条读数都由这份差异决定 ----
6425
+ /** 与基线不同的配置字段(保存时合成一条 mutate 的 ops)。 */
6426
+ const pendingOps = diffOps(draft, baseline);
6427
+ /** 还挂在草稿里的凭据引用名(授权码也算一项改动)。 */
6428
+ const pendingCredRefs = pendingCredentialRefs();
6429
+ /** 未保存的改动数:N 个配置字段 + 至多一条授权码。 */
6430
+ const dirtyCount = pendingOps.length + pendingCredRefs.length;
6431
+ /** 读数那一档的色调。半成功也算异常:那种状态绝不能长成「一切正常」的样子。 */
6432
+ const saveTone = save.phase === 'error' || save.phase === 'unserved' || save.phase === 'loadFailed'
6433
+ || save.phase === 'partialCred' || save.phase === 'partialConfig'
6434
+ ? 'error'
6435
+ : (save.phase === 'saving' ? 'busy' : (dirtyCount > 0 ? 'dirty' : 'idle'));
6493
6436
  /**
6494
- * 最近一次投递。没有就返回 null(此时只说"还没试过",不摆一行空的)。
6495
- *
6496
- * 它同时是页面的第一条排错线索:分类(认证被拒 / 发件人被拒 / 网络)给出"该动哪里",
6497
- * 原始报错原样附在后面——不改写才是真的可排错。
6437
+ * 保存读数。只说这一刻的事实:干净 / 脏 / 保存中 / 已保存多久 / 失败原因 + 重试,
6438
+ * 以及三种非干净结局各自的动作——读失败给「重新读取」、冲突给「再点一次保存」、
6439
+ * 半成功只给失败那一半的重试。动作永远指向**这一次真正该做的事**,不是笼统的「重试」。
6440
+ * @returns {object[]} 读数节点
6498
6441
  */
6499
- const deliveryView = lastDelivery === null ? null : (() => {
6500
- const error = classifySendError(lastDelivery.error);
6501
- return {
6502
- ok: lastDelivery.ok === true,
6503
- ago: relativeTime(lastDelivery.at, now),
6504
- elapsed: Number(lastDelivery.ms ?? 0),
6505
- raw: String(lastDelivery.error ?? ''),
6506
- error,
6442
+ const saveReadout = () => {
6443
+ if (save.phase === 'unserved') return [t('status.notServed')];
6444
+ if (save.phase === 'loadFailed') {
6445
+ // 读失败不是「保存失败」:这里一个字都不许写回,只重读。
6446
+ return [
6447
+ t('save.loadFailed', { error: save.error }),
6448
+ h('button', {
6449
+ type: 'button',
6450
+ className: 'dshmn-link',
6451
+ onClick: () => void load(),
6452
+ }, t('save.reload')),
6453
+ ];
6454
+ }
6455
+ if (save.phase === 'conflictReloaded') return [t('save.conflictReloaded')];
6456
+ if (save.phase === 'partialCred') {
6457
+ // 配置写进去了、授权码没写进去:只给授权码那一半的重试。
6458
+ return [
6459
+ t('save.credFailed', { error: save.error }),
6460
+ h('button', {
6461
+ type: 'button',
6462
+ className: 'dshmn-link',
6463
+ onClick: () => void retryCredentials(),
6464
+ }, t('save.retryCred')),
6465
+ ];
6466
+ }
6467
+ if (save.phase === 'partialConfig') {
6468
+ // 授权码写进去了、配置没写进去:只给配置那一半的重试。
6469
+ return [
6470
+ t('save.configFailed', { error: save.error }),
6471
+ h('button', {
6472
+ type: 'button',
6473
+ className: 'dshmn-link',
6474
+ onClick: () => void retryConfig(),
6475
+ }, t('save.retryConfig')),
6476
+ ];
6477
+ }
6478
+ if (save.phase === 'error') {
6479
+ return [
6480
+ t('save.error', { error: save.error }),
6481
+ String(save.credError ?? '') === '' ? null : t('save.credAlsoFailed', { error: save.credError }),
6482
+ h('button', {
6483
+ type: 'button',
6484
+ className: 'dshmn-link',
6485
+ onClick: () => void saveAll(),
6486
+ }, t('save.retry')),
6487
+ ].filter((node) => node !== null);
6488
+ }
6489
+ if (save.phase === 'saving') return [t('save.saving')];
6490
+ if (dirtyCount > 0) return [t('save.dirty', { n: dirtyCount })];
6491
+ if (save.phase === 'saved' && save.at > 0) {
6492
+ const seconds = Math.max(0, Math.round((now - save.at) / 1000));
6493
+ return [seconds === 0 ? t('save.savedJust') : t('save.savedAgo', { n: seconds })];
6494
+ }
6495
+ return [t('save.clean')];
6496
+ };
6497
+
6498
+ // 有未保存改动时,关闭 / 离开页面要拦一下;没有改动就完全静默(日常切页不该被打断)。
6499
+ React.useEffect(() => {
6500
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return undefined;
6501
+ if (dirtyCount === 0) return undefined;
6502
+ const guard = (event) => {
6503
+ event.preventDefault();
6504
+ event.returnValue = t('save.dirty', { n: dirtyCount });
6505
+ return event.returnValue;
6507
6506
  };
6508
- })();
6507
+ window.addEventListener('beforeunload', guard);
6508
+ return () => window.removeEventListener('beforeunload', guard);
6509
+ }, [dirtyCount]);
6509
6510
 
6510
- /** 测试按钮文案:发的时候说在发;之前失败过就说重测。 */
6511
+ /** 测试按钮文案:发的时候说在发;刚才那次没成功就说重测。 */
6511
6512
  const testLabel = sending
6512
6513
  ? t('act.testing')
6513
- : (test.phase === 'fail' || (lastDelivery !== null && lastDelivery.ok === false) ? t('act.retest') : t('act.test'));
6514
+ : (test.phase === 'fail' ? t('act.retest') : t('act.test'));
6514
6515
 
6515
6516
  /**
6516
6517
  * 「发送测试邮件」——本页唯一的主行动。
@@ -6522,10 +6523,10 @@ window.__ModuleLoader__.load({
6522
6523
  * @returns {object|null} React 元素
6523
6524
  */
6524
6525
  const testButton = (options) => {
6525
- if (capability === 'unknown') return null;
6526
- if (!testSupported) return h('span', { className: 'dshmn-hint' }, t('act.needsRestart'));
6527
- // 凭据行里用文字链接:手刚离开输入框,验证就在原地,不用滚回页顶;
6528
- // 但它不抢判决条的主按钮,所以只做链接。
6526
+ if (!bridgeReady) return null;
6527
+ if (!rpcReady) return h('span', { className: 'dshmn-hint' }, t('act.needsRestart'));
6528
+ // 凭据行那处用文字链接:手刚离开授权码输入框,验证就在原地,不必滚回页首。
6529
+ // 它和判决条是同一个动作,但主次分明——判决条是主按钮,这里是弱入口。
6529
6530
  if (options?.variant === 'link') {
6530
6531
  return h('button', {
6531
6532
  type: 'button',
@@ -6546,68 +6547,78 @@ window.__ModuleLoader__.load({
6546
6547
  };
6547
6548
 
6548
6549
  /**
6549
- * 判决条的动作 = **下一步该做的那一件事**(主按钮)+ 可选的一次验证。
6550
+ * 判决条的动作区:**全页唯一主行动**是「发送测试邮件」,实心主按钮落在右下角。
6550
6551
  *
6551
- * 还没配完时主按钮是"去填写"(把光标送到缺的那一格),配好了才是发测试邮件;
6552
- * 验证按钮任何时候都在,因为"还差什么"恰恰要靠它一次点击问出来。
6552
+ * 它不挑状态:配置只填了一半时,"怎么验证"恰恰最需要出现(一次点击就把缺什么问出来)。
6553
+ * 没配完时左边再给一个次级按钮「启用 / 去填写」——它是下一步,但不跟主行动抢主次:
6554
+ * 已关闭时是「启用」,缺字段时是「去填写」(把光标送到缺的那一格)。
6555
+ * @returns {object[]} [次要的下一步, 实心主按钮],主按钮永远在最后(右下角)
6553
6556
  */
6554
- const configAction = (() => {
6555
- if (statusKey === 'disabled') {
6556
- return h(Button, {
6557
- variant: 'primary',
6558
- size: 'sm',
6559
- onClick: () => setField('enabled', true, { immediate: true }),
6560
- }, t('act.enable'));
6561
- }
6562
- if (statusKey === 'ready' || statusKey === 'lastFailed') return testButton({ variant: 'primary' });
6563
- return h(Button, {
6564
- variant: 'primary',
6565
- size: 'sm',
6566
- onClick: () => focusMissing(),
6567
- }, t('act.goFill'));
6568
- })();
6569
- /** 还没配完时,测试仍然给,但降为次级:它是验证,不是下一步。 */
6570
- const secondaryTest = (statusKey === 'ready' || statusKey === 'lastFailed') ? null : testButton();
6557
+ const verdictActions = () => {
6558
+ const nextStep = statusKey === 'ready'
6559
+ ? null
6560
+ : (statusKey === 'disabled'
6561
+ ? h(Button, {
6562
+ variant: 'outline',
6563
+ size: 'sm',
6564
+ onClick: () => setField('enabled', true),
6565
+ }, t('act.enable'))
6566
+ : h(Button, {
6567
+ variant: 'outline',
6568
+ size: 'sm',
6569
+ onClick: () => focusMissing(),
6570
+ }, t('act.goFill')));
6571
+ return [nextStep, testButton({ variant: 'primary' })];
6572
+ };
6573
+
6574
+ /** 把光标送到某一格(元素不在就什么也不做)。 */
6575
+ const focusField = (id) => {
6576
+ const element = typeof document === 'undefined' || typeof document.getElementById !== 'function'
6577
+ ? null
6578
+ : document.getElementById(id);
6579
+ if (element === null || element === undefined) return;
6580
+ if (typeof element.scrollIntoView === 'function') element.scrollIntoView({ block: 'center' });
6581
+ if (typeof element.focus === 'function') element.focus();
6582
+ };
6571
6583
 
6572
6584
  /** 把用户送到缺的那一格,而不是让他自己在一屏里找。 */
6573
6585
  const focusMissing = () => {
6574
- const target = statusKey === 'needRecipients' ? 'dshmn-recipients'
6586
+ focusField(statusKey === 'needRecipients' ? 'dshmn-recipients'
6575
6587
  : (statusKey === 'needHost' ? 'dshmn-smtp-host'
6576
- : (statusKey === 'needUser' ? 'dshmn-smtp-user' : 'dshmn-cred-smtp'));
6577
- const element = typeof document === 'undefined' ? null : document.getElementById(target);
6578
- if (element === null || element === undefined) return;
6579
- element.scrollIntoView({ block: 'center' });
6580
- element.focus();
6588
+ : (statusKey === 'needUser' ? 'dshmn-smtp-user' : 'dshmn-cred-smtp')));
6581
6589
  };
6582
6590
 
6583
6591
  /**
6584
- * 判决条底部那条"读数"。
6592
+ * 「发送测试邮件」这次点击的回执。
6585
6593
  *
6586
- * 它是这一页唯一能回答"真发得出去吗"的东西,所以放在判决条里、用一条发丝线跟判决分开。
6587
- * 失败时分类 + 原文都给:只给"发送失败"四个字等于没给线索。
6594
+ * 账本删掉之后,这里是**唯一**能回答「真投递过没有」的地方,所以它必须
6595
+ * 成功给用时、失败给分类 + 原文:只报一句「已发送」等于把人推回日志里。
6596
+ * 状态只活在内存里、刷新页面即消失——它报的是这一次点击,不是历史。
6588
6597
  * @returns {object|null} React 元素
6589
6598
  */
6590
- const evidence = () => {
6591
- if (deliveryView === null) {
6599
+ const testResult = () => {
6600
+ if (test.phase === 'sending') {
6592
6601
  return h('div', { className: 'dshmn-evidence' },
6593
- h('span', { className: 'dshmn-evidenceKey' }, t('evidence.label')),
6594
- h('span', null, t('evidence.never')));
6602
+ h('span', { className: 'dshmn-evidenceKey' }, t('act.testing')));
6595
6603
  }
6596
- const view = deliveryView;
6597
- const detail = view.ok
6598
- ? [h('span', { key: 'ok' }, t('evidence.ok', { ms: view.elapsed, ago: view.ago }))]
6599
- : [
6600
- h('span', { key: 'reason' }, `${t('evidence.fail')}:${view.error.label}(${view.ago})`),
6601
- h('span', { key: 'hint', className: 'dshmn-hint' }, view.error.hint),
6602
- h('span', { key: 'raw', className: 'dshmn-mono' }, view.raw),
6603
- ];
6604
- return h('div', { className: 'dshmn-evidence', 'data-ok': view.ok ? 'true' : 'false' },
6605
- h('span', { className: 'dshmn-evidenceKey' }, t('evidence.label')),
6606
- ...detail);
6604
+ if (test.phase === 'ok') {
6605
+ return h('div', { className: 'dshmn-evidence', 'data-ok': 'true' },
6606
+ h('span', { className: 'dshmn-evidenceKey' }, t('test.sent')),
6607
+ h('span', null, t('test.sentMs', { ms: Number(test.ms ?? 0) })));
6608
+ }
6609
+ if (test.phase === 'fail') {
6610
+ const reason = classifySendError(test.error);
6611
+ return h('div', { className: 'dshmn-evidence', 'data-ok': 'false' },
6612
+ h('span', { className: 'dshmn-evidenceKey' }, t('test.failed')),
6613
+ h('span', null, reason.label),
6614
+ h('span', { className: 'dshmn-hint' }, reason.hint),
6615
+ h('span', { className: 'dshmn-mono' }, String(test.error ?? '')));
6616
+ }
6617
+ return null;
6607
6618
  };
6608
6619
  /**
6609
6620
  * 凭据行。保存前给输入框(这里才是要动手的地方);保存后**不再摆一个空输入框**——
6610
- * 那样只会让人怀疑"是不是没存上"。存好之后是:✓ 已保存 + 测试 + 更换 + 清除。
6621
+ * 那样只会让人怀疑"是不是没存上"。存好之后是:✓ 已保存 + 更换 + 清除(测试入口全页只在判决条一处)。
6611
6622
  * @param {string} fieldId 输入框 id
6612
6623
  * @param {string} labelKey 名称文案键
6613
6624
  * @param {string} ref 凭据引用名(写入目标由调用方给定,硬约束 4)
@@ -6617,16 +6628,21 @@ window.__ModuleLoader__.load({
6617
6628
  */
6618
6629
  const credentialField = (fieldId, labelKey, ref, hintKey, helpKey) => {
6619
6630
  const confirmed = configured(ref);
6620
- const clear = pwClear[ref] === true;
6621
- const editing = confirmed !== true || clear;
6631
+ // 「更换」只是把输入框展开,不算一项改动;真正待清理的是 pwClear。
6632
+ const editing = confirmed !== true || pwEdit[ref] === true;
6633
+ // 「怎么拿授权码?」从行内链接收成标题旁的问号:和 8 个事件开关同一套交互,
6634
+ // 省一行宽度,把位置让给真正的动作(更换 / 清除 / 发送测试邮件)。
6622
6635
  const helpButton = h('button', {
6623
6636
  type: 'button',
6624
- className: 'dshmn-link',
6637
+ className: 'dshmn-q',
6638
+ 'aria-label': t(helpKey),
6625
6639
  onClick: () => setHelpKind(true),
6626
- }, t(helpKey));
6640
+ }, '?');
6627
6641
  return h('div', { className: 'dshmn-item' },
6628
6642
  h('div', { className: 'dshmn-itemHead' },
6629
- h('label', { className: 'dshmn-label', htmlFor: fieldId }, t(labelKey)),
6643
+ h('span', { className: 'dshmn-labelRow' },
6644
+ h('label', { className: 'dshmn-label', htmlFor: fieldId }, t(labelKey)),
6645
+ helpButton),
6630
6646
  h('span', { className: 'dshmn-credState', 'data-ok': confirmed ? 'true' : 'false' },
6631
6647
  confirmed ? h('span', { 'aria-hidden': 'true' }, h(CheckedMark, null)) : null,
6632
6648
  confirmed ? t('cred.saved') : t('cred.notSaved'))),
@@ -6641,8 +6657,6 @@ window.__ModuleLoader__.load({
6641
6657
  setPwDraft((prev) => ({ ...prev, [ref]: value }));
6642
6658
  setPwClear((prev) => ({ ...prev, [ref]: false }));
6643
6659
  },
6644
- onBlur: () => void commitCredential(ref),
6645
- onKeyDown: (event) => { if (event.key === 'Enter') void commitCredential(ref); },
6646
6660
  })
6647
6661
  : h('div', null,
6648
6662
  h(TextInput, {
@@ -6652,7 +6666,7 @@ window.__ModuleLoader__.load({
6652
6666
  readOnly: true,
6653
6667
  'aria-label': `${t(labelKey)}:${t('cred.saved')}`,
6654
6668
  placeholder: t('cred.saved'),
6655
- onFocus: () => setPwClear((prev) => ({ ...prev, [ref]: true })),
6669
+ onFocus: () => setPwEdit((prev) => ({ ...prev, [ref]: true })),
6656
6670
  })),
6657
6671
  h('div', { className: 'dshmn-hint' }, t(hintKey)),
6658
6672
  save.phase === 'saving' ? h('div', { className: 'dshmn-hint' }, t('save.checkingCred')) : null,
@@ -6669,7 +6683,7 @@ window.__ModuleLoader__.load({
6669
6683
  ? h('button', {
6670
6684
  type: 'button',
6671
6685
  className: 'dshmn-link',
6672
- onClick: () => setPwClear((prev) => ({ ...prev, [ref]: true })),
6686
+ onClick: () => setPwEdit((prev) => ({ ...prev, [ref]: true })),
6673
6687
  }, t('cred.replace'))
6674
6688
  : null,
6675
6689
  confirmed
@@ -6679,40 +6693,97 @@ window.__ModuleLoader__.load({
6679
6693
  onClick: () => {
6680
6694
  setPwDraft((prev) => ({ ...prev, [ref]: '' }));
6681
6695
  setPwClear((prev) => ({ ...prev, [ref]: true }));
6682
- void commitCredential(ref);
6683
6696
  },
6684
6697
  }, t('cred.clear'))
6685
6698
  : null,
6686
- helpButton,
6687
6699
  testButton({ variant: 'link' })));
6688
6700
  };
6689
6701
 
6690
- /** 分组头右侧那一格:只说这一段的结论,不摆装饰。 */
6702
+ /**
6703
+ * 一个事件开关的紧凑行:标题 + 问号 + 开关。
6704
+ *
6705
+ * 说明不再常显(8 行说明会把这一组撑成一屏),收进问号后面:**点击**开合而不是 hover,
6706
+ * 触屏点得到;原生 button + aria-expanded / aria-controls,键盘 tab 得到。
6707
+ * 说明文案仍然来自原来那对键(outcomes.*Hint),一个字都没重写。
6708
+ * @param {string} field 配置字段名
6709
+ * @param {string} titleKey 标题文案键
6710
+ * @param {string} hintKey 说明文案键
6711
+ * @returns {object} React 元素
6712
+ */
6713
+ const outcomeRow = (field, titleKey, hintKey) => {
6714
+ const open = openHints[field] === true;
6715
+ const noteId = `dshmn-note-${field}`;
6716
+ return h('div', { className: 'dshmn-row', key: field },
6717
+ h('div', { className: 'dshmn-rowHead' },
6718
+ h('div', { className: 'dshmn-rowTitle' },
6719
+ h('span', { className: 'dshmn-rowName' }, t(titleKey)),
6720
+ h('button', {
6721
+ type: 'button',
6722
+ className: 'dshmn-q',
6723
+ 'aria-label': t(open ? 'hint.hide' : 'hint.show', { title: t(titleKey) }),
6724
+ 'aria-expanded': open ? 'true' : 'false',
6725
+ 'aria-controls': noteId,
6726
+ onClick: () => setOpenHints((prev) => ({ ...prev, [field]: prev[field] !== true })),
6727
+ }, '?')),
6728
+ h('div', { className: 'dshmn-rowCtrl' }, h(Switch, {
6729
+ checked: draft[field] === true,
6730
+ label: t(titleKey),
6731
+ onChange: (next) => setField(field, next),
6732
+ }))),
6733
+ open ? h('p', { className: 'dshmn-rowNote', id: noteId }, t(hintKey)) : null);
6734
+ };
6735
+
6736
+ /**
6737
+ * 「更多」标题上的当前值。
6738
+ *
6739
+ * 端口 / 加密收进折叠区,但"现在到底用什么"必须一眼看得到:只要这一对值偏离宿主
6740
+ * 默认(465 · auto,见 lib/notify/config.js),就把它们摆到标题上。
6741
+ */
6742
+ const draftPort = draft.smtpPort === undefined || draft.smtpPort === null ? '' : String(draft.smtpPort);
6743
+ const draftSecure = String(draft.smtpSecure ?? '');
6744
+ const transportTweaked = (draftPort !== '' && Number(draftPort) !== DEFAULT_TRANSPORT.port)
6745
+ || (draftSecure !== '' && draftSecure !== DEFAULT_TRANSPORT.secure);
6746
+ const transportValue = [draftPort, draftSecure].filter((part) => part !== '').join(' · ');
6747
+ const moreTitle = transportTweaked && transportValue !== ''
6748
+ ? t('transport.moreWith', { value: transportValue })
6749
+ : t('transport.more');
6750
+
6751
+ /** 收件人那一组的读数:只说个数,落在段头的小字里(不再占一行正文)。 */
6691
6752
  const recipientsState = recipients.valid.length > 0
6692
6753
  ? t('recipients.ok', { n: recipients.valid.length })
6693
6754
  : t('recipients.none');
6694
6755
 
6695
6756
  return h('div', { className: 'dshmn-page', 'data-build': BUILD },
6696
6757
 
6697
- // ---- 页头:这一页干什么 + 落盘读数(读数常驻,不自灭)----
6698
- h('div', { className: 'dshmn-head' },
6699
- h('p', { className: 'dshmn-lead' }, t('lead')),
6700
- h('div', {
6701
- className: 'dshmn-stamp',
6702
- 'data-tone': save.phase === 'error' ? 'error' : (save.phase === 'saving' ? 'busy' : 'idle'),
6703
- 'aria-live': 'polite',
6704
- },
6705
- save.phase === 'error'
6706
- ? h(React.Fragment, null,
6707
- t('save.error', { error: save.error }),
6708
- h('button', { type: 'button', className: 'dshmn-link', onClick: () => void load() }, t('save.reload')))
6709
- : (save.phase === 'saving'
6710
- ? t('save.saving')
6711
- : (save.phase === 'saved' && save.at > 0
6712
- ? t('save.savedAgo', { n: Math.max(0, Math.round((now - save.at) / 1000)) })
6713
- : t('save.auto'))))),
6714
-
6715
- // ---- 判决条:全页唯一的填充面 ----
6758
+ // ---- 页头:标题独占一行(18/600),右上只有动作组 ----
6759
+ // 「脏读数 + 保存 + 放弃改动」是一个动作区:不画线、不放说明、不占第二层高度。
6760
+ // 宿主的设置面板头(h54)只放 actions + close,所以内容区第一行就该是标题。
6761
+ h('header', { className: 'dshmn-head' },
6762
+ h('div', { className: 'dshmn-headText' },
6763
+ h('h2', { className: 'dshmn-title' }, t('nav')),
6764
+ h('p', { className: 'dshmn-lead' }, t('lead'))),
6765
+ h('div', { className: 'dshmn-headActions' },
6766
+ h('div', {
6767
+ className: 'dshmn-stamp',
6768
+ 'data-tone': saveTone,
6769
+ 'aria-live': 'polite',
6770
+ }, ...saveReadout()),
6771
+ h(Button, {
6772
+ variant: 'primary',
6773
+ size: 'sm',
6774
+ // 没有改动就禁用:主按钮不该在无事可做时还亮着。
6775
+ disabled: dirtyCount === 0 || !bridgeReady || save.phase === 'saving',
6776
+ title: t('act.saveHint'),
6777
+ onClick: () => void saveAll(),
6778
+ }, t('act.save')),
6779
+ h(Button, {
6780
+ variant: 'outline',
6781
+ size: 'sm',
6782
+ disabled: dirtyCount === 0 || !bridgeReady || save.phase === 'saving',
6783
+ onClick: () => discardAll(),
6784
+ }, t('act.discard')))),
6785
+
6786
+ // ---- 判决条:全页唯一的填充面(状态色 hero);结论句 + 右下角那处主行动 ----
6716
6787
  h('section', {
6717
6788
  className: 'dshmn-verdict',
6718
6789
  'data-tone': tone,
@@ -6723,11 +6794,13 @@ window.__ModuleLoader__.load({
6723
6794
  h(Dot, { state: tone === 'done' ? 'done' : (tone === 'error' ? 'error' : (tone === 'warning' ? 'warning' : 'idle')) }),
6724
6795
  h('h2', { className: 'dshmn-verdictTitle' }, statusTitle)),
6725
6796
  h('p', { className: 'dshmn-verdictWhy' }, statusWhy),
6726
- evidence(),
6727
- h('div', { className: 'dshmn-verdictFoot' }, secondaryTest, configAction)),
6797
+ // 口径明写:这句判决算的是**已保存的配置**,屏幕上没保存的改动不算数。
6798
+ dirtyCount > 0 ? h('p', { className: 'dshmn-hint' }, t('status.unsavedDraft')) : null,
6799
+ testResult(),
6800
+ h('div', { className: 'dshmn-verdictFoot' }, ...verdictActions())),
6728
6801
 
6729
- // ---- 收件人 ----
6730
- h(Group, { title: t('group.recipients'), state: recipientsState },
6802
+ // ---- 卡片一:收件人 ----
6803
+ h(Card, { kind: 'recipients', title: t('group.recipients'), state: recipientsState },
6731
6804
  h('div', { className: 'dshmn-item' },
6732
6805
  h('div', { className: 'dshmn-field' },
6733
6806
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-recipients' }, t('recipients.label')),
@@ -6738,41 +6811,40 @@ window.__ModuleLoader__.load({
6738
6811
  placeholder: t('recipients.placeholder'),
6739
6812
  'aria-invalid': recipients.invalid.length > 0 ? 'true' : 'false',
6740
6813
  onChange: (event) => setField('recipients', event.target.value),
6741
- onBlur: () => flush('recipients'),
6742
6814
  }),
6743
6815
  h('div', { className: 'dshmn-hint' }, t('recipients.hint')),
6744
6816
  recipients.invalid.length > 0
6745
6817
  ? h('div', { className: 'dshmn-error', role: 'alert' }, t('recipients.invalid', { list: recipients.invalid.join('、') }))
6746
6818
  : null))),
6747
6819
 
6748
- // ---- 怎么发(只剩 SMTP 一种发送方式,不再让人选通道)----
6749
- h(Group, { title: t('group.transport'), state: transportState },
6820
+ // ---- 卡片二:怎么发(只剩 SMTP 一种发送方式,不再让人选通道)----
6821
+ h(Card, { kind: 'transport', title: t('group.transport'), state: transportState },
6750
6822
  h('div', { className: 'dshmn-item' },
6751
6823
  h('div', { className: 'dshmn-hint' }, t('smtp.note')),
6752
- h('div', { className: 'dshmn-sub' },
6753
6824
 
6754
- h('div', { className: 'dshmn-field' },
6755
- h('div', { className: 'dshmn-label' }, t('preset.label')),
6756
- h('div', { className: 'dshmn-seg', role: 'radiogroup', 'aria-label': t('preset.label') },
6757
- ...PRESETS.map((item) => h(Segment, {
6758
- key: item.id,
6759
- active: preset !== null && preset.id === item.id,
6760
- onClick: () => setFields({
6761
- smtpHost: item.host,
6762
- smtpPort: item.port,
6763
- smtpSecure: item.secure,
6764
- ...(draft.smtpUser === '' && userForPreset(item, recipients.valid) !== ''
6765
- ? { smtpUser: userForPreset(item, recipients.valid) }
6766
- : {}),
6767
- }),
6768
- }, item.name)),
6769
- h(Segment, {
6770
- active: preset === null,
6771
- onClick: () => setShowAdvanced(true),
6772
- }, t('preset.other'))),
6773
- preset === null ? null : h('div', { className: 'dshmn-hint' }, t('preset.detected', { name: preset.name }))),
6774
-
6775
- h('div', { className: 'dshmn-grid' },
6825
+ // 7 个服务商收成一行横向滚动的 chips:窄屏不折成三排,「其他」永远在最右。
6826
+ h('div', { className: 'dshmn-chips', role: 'radiogroup', 'aria-label': t('preset.label') },
6827
+ ...PRESETS.map((item) => h(Segment, {
6828
+ key: item.id,
6829
+ active: preset !== null && preset.id === item.id,
6830
+ onClick: () => setFields({
6831
+ smtpHost: item.host,
6832
+ smtpPort: item.port,
6833
+ smtpSecure: item.secure,
6834
+ ...(draft.smtpUser === '' && userForPreset(item, recipients.valid) !== ''
6835
+ ? { smtpUser: userForPreset(item, recipients.valid) }
6836
+ : {}),
6837
+ }),
6838
+ }, item.name)),
6839
+ h(Segment, {
6840
+ active: preset === null,
6841
+ // 「其他」= 自建中继:光标直接送到主机那一格
6842
+ onClick: () => focusField('dshmn-smtp-host'),
6843
+ }, t('preset.other'))),
6844
+ preset === null ? null : h('div', { className: 'dshmn-hint' }, t('preset.detected', { name: preset.name })),
6845
+
6846
+ // 主行:主机 + 用户名(两列)。端口 / 加密折进下面的「更多」。
6847
+ h('div', { className: 'dshmn-two' },
6776
6848
  h('div', { className: 'dshmn-field' },
6777
6849
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-host' }, t('smtp.host')),
6778
6850
  h(TextInput, {
@@ -6781,293 +6853,86 @@ window.__ModuleLoader__.load({
6781
6853
  value: draft.smtpHost ?? '',
6782
6854
  placeholder: 'smtp.example.com',
6783
6855
  onChange: (event) => setField('smtpHost', event.target.value),
6784
- onBlur: () => flush('smtpHost'),
6785
6856
  })),
6786
6857
  h('div', { className: 'dshmn-field' },
6787
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-port' }, t('smtp.port')),
6858
+ h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-user' }, t('smtp.user')),
6788
6859
  h(TextInput, {
6789
- id: 'dshmn-smtp-port',
6860
+ id: 'dshmn-smtp-user',
6790
6861
  type: 'text',
6791
- inputMode: 'numeric',
6792
- value: draft.smtpPort ?? '',
6793
- placeholder: '465',
6794
- 'aria-label': t('smtp.port'),
6795
- onChange: (event) => setField('smtpPort', event.target.value === '' ? '' : Number(event.target.value), { delay: NUMBER_DEBOUNCE_MS }),
6796
- onBlur: () => flush('smtpPort'),
6797
- })),
6798
- h('div', { className: 'dshmn-field' },
6799
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-secure' }, t('smtp.secure')),
6800
- h('select', {
6801
- id: 'dshmn-smtp-secure',
6802
- className: 'dshmn-select',
6803
- value: draft.smtpSecure ?? 'auto',
6804
- onChange: (event) => setField('smtpSecure', event.target.value, { immediate: true }),
6805
- }, ...[['auto', 'auto'], ['tls', 'tls'], ['starttls', 'starttls'], ['plain', 'plain']]
6806
- .map(([value, text]) => h('option', { key: value, value }, text))))),
6807
-
6862
+ value: draft.smtpUser ?? '',
6863
+ placeholder: `you${preset?.suffix ?? '@example.com'}`,
6864
+ onChange: (event) => setField('smtpUser', event.target.value),
6865
+ }),
6866
+ accountMismatch(preset, draft.smtpUser)
6867
+ ? h('div', { className: 'dshmn-error', role: 'alert' }, t('smtp.accountMismatch', {
6868
+ host: String(draft.smtpHost ?? ''),
6869
+ name: preset?.name ?? '',
6870
+ user: String(draft.smtpUser ?? ''),
6871
+ suffix: preset?.suffix ?? '',
6872
+ }))
6873
+ : null)),
6874
+
6875
+ // 端口 / 加密:默认收起。收起时标题上写清当前值(偏离默认才有内容),
6876
+ // 所以"收起来"不等于"藏起来"——想知道现在用什么,看一眼标题就够。
6808
6877
  h('div', { className: 'dshmn-field' },
6809
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-user' }, t('smtp.user')),
6810
- h(TextInput, {
6811
- id: 'dshmn-smtp-user',
6812
- type: 'text',
6813
- value: draft.smtpUser ?? '',
6814
- placeholder: `you${preset?.suffix ?? '@example.com'}`,
6815
- onChange: (event) => setField('smtpUser', event.target.value),
6816
- onBlur: () => flush('smtpUser'),
6817
- }),
6818
- accountMismatch(preset, draft.smtpUser)
6819
- ? h('div', { className: 'dshmn-error', role: 'alert' }, t('smtp.accountMismatch', {
6820
- host: String(draft.smtpHost ?? ''),
6821
- name: preset?.name ?? '',
6822
- user: String(draft.smtpUser ?? ''),
6823
- suffix: preset?.suffix ?? '',
6824
- }))
6825
- : null),
6826
-
6827
- credentialField('dshmn-cred-smtp', 'cred.smtp', smtpPasswordRef, 'cred.smtpHint', 'cred.smtpHelp')))),
6828
-
6829
- // ---- 什么时候发给我 ----
6830
- h(Group, {
6878
+ foldHead('dshmn-more-transport', moreTitle, moreOpen, () => setMoreOpen((value) => value !== true)),
6879
+ moreOpen !== true
6880
+ ? null
6881
+ : h('div', { className: 'dshmn-foldBody', id: 'dshmn-more-transport' },
6882
+ h('div', { className: 'dshmn-twoPort' },
6883
+ h('div', { className: 'dshmn-field' },
6884
+ h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-port' }, t('smtp.port')),
6885
+ h(TextInput, {
6886
+ id: 'dshmn-smtp-port',
6887
+ type: 'text',
6888
+ inputMode: 'numeric',
6889
+ value: draft.smtpPort ?? '',
6890
+ placeholder: '465',
6891
+ 'aria-label': t('smtp.port'),
6892
+ onChange: (event) => {
6893
+ const raw = String(event.target.value ?? '');
6894
+ // 端口只收数字。Number('46a') 是 NaN:宿主会拒写,但报的是英文 SDK 错误,
6895
+ // 而且 NaN 跟谁都不相等,脏计数会一直挂着不清。非法字符根本别进草稿——
6896
+ // 受控输入会把框里的内容退回上一次的合法值。
6897
+ if (raw !== '' && !/^\d+$/.test(raw)) return;
6898
+ setField('smtpPort', raw === '' ? '' : Number(raw));
6899
+ },
6900
+ })),
6901
+ h('div', { className: 'dshmn-field' },
6902
+ h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-secure' }, t('smtp.secure')),
6903
+ h('select', {
6904
+ id: 'dshmn-smtp-secure',
6905
+ className: 'dshmn-select',
6906
+ value: draft.smtpSecure ?? 'auto',
6907
+ onChange: (event) => setField('smtpSecure', event.target.value),
6908
+ }, ...[['auto', 'auto'], ['tls', 'tls'], ['starttls', 'starttls'], ['plain', 'plain']]
6909
+ .map(([value, text]) => h('option', { key: value, value }, text)))))))),
6910
+ credentialField('dshmn-cred-smtp', 'cred.smtp', smtpPasswordRef, 'cred.smtpHint', 'cred.smtpHelp')),
6911
+
6912
+ // ---- 卡片三:什么时候发给我(8 个开关单列,宿主同款行;窄屏最稳)----
6913
+ // 说明不再常显:每行标题旁一个问号,点开才显示那一行的说明(文案键原样复用)。
6914
+ h(Card, {
6915
+ kind: 'outcomes',
6831
6916
  title: t('group.outcomes'),
6832
6917
  state: outcomesOn > 0 ? t('outcomes.countOn', { n: outcomesOn }) : t('outcomes.countOff'),
6833
6918
  },
6834
- // 时长下限是"任务完成"的子项(只对它生效),所以缩进成子行——因果关系看得见,
6835
- // 而不是隔着十行再补一句"只对完成生效"。
6919
+ outcomeRow('notifyCompleted', 'outcomes.completed', 'outcomes.completedHint'),
6920
+ outcomeRow('notifyError', 'outcomes.error', 'outcomes.errorHint'),
6921
+ outcomeRow('notifyBlocked', 'outcomes.blocked', 'outcomes.blockedHint'),
6922
+ outcomeRow('notifyApproval', 'outcomes.approval', 'outcomes.approvalHint'),
6923
+ outcomeRow('includeSubagents', 'outcomes.subagents', 'outcomes.subagentsHint'),
6836
6924
  h('div', { className: 'dshmn-item' },
6837
- h('div', { className: 'dshmn-itemHead' },
6838
- h('div', { className: 'dshmn-itemText' },
6839
- h('div', { className: 'dshmn-itemTitle' }, t('outcomes.completed')),
6840
- h('div', { className: 'dshmn-hint' }, t('outcomes.completedHint'))),
6841
- h('div', { className: 'dshmn-itemCtrl' },
6842
- h(Switch, {
6843
- checked: draft.notifyCompleted === true,
6844
- label: t('outcomes.completed'),
6845
- onChange: (next) => setField('notifyCompleted', next, { immediate: true }),
6846
- }))),
6847
- h('div', { className: 'dshmn-sub', 'data-muted': draft.notifyCompleted === true ? 'false' : 'true' },
6848
- h('div', { className: 'dshmn-itemHead' },
6849
- h('div', { className: 'dshmn-itemText' },
6850
- h('label', { className: 'dshmn-itemTitle', htmlFor: 'dshmn-minduration' }, t('outcomes.minDuration')),
6851
- h('div', { className: 'dshmn-hint' }, t('outcomes.minDurationHint'))),
6852
- h('div', { className: 'dshmn-itemCtrl' },
6853
- h(NumberField, {
6854
- id: 'dshmn-minduration',
6855
- value: draft.minDurationMs,
6856
- placeholder: '2000',
6857
- unit: t('unit.ms'),
6858
- label: t('outcomes.minDuration'),
6859
- onChange: (value) => setField('minDurationMs', value, { delay: NUMBER_DEBOUNCE_MS }),
6860
- onBlur: () => flush('minDurationMs'),
6861
- }))))),
6862
- h(Row, {
6863
- title: t('outcomes.error'),
6864
- hint: t('outcomes.errorHint'),
6865
- control: h(Switch, {
6866
- checked: draft.notifyError === true,
6867
- label: t('outcomes.error'),
6868
- onChange: (next) => setField('notifyError', next, { immediate: true }),
6869
- }),
6870
- }),
6871
- h(Row, {
6872
- title: t('outcomes.blocked'),
6873
- hint: t('outcomes.blockedHint'),
6874
- control: h(Switch, {
6875
- checked: draft.notifyBlocked === true,
6876
- label: t('outcomes.blocked'),
6877
- onChange: (next) => setField('notifyBlocked', next, { immediate: true }),
6878
- }),
6879
- }),
6880
- h(Row, {
6881
- title: t('outcomes.approval'),
6882
- hint: t('outcomes.approvalHint'),
6883
- control: h(Switch, {
6884
- checked: draft.notifyApproval === true,
6885
- label: t('outcomes.approval'),
6886
- onChange: (next) => setField('notifyApproval', next, { immediate: true }),
6887
- }),
6888
- }),
6889
- h(Row, {
6890
- title: t('outcomes.subagents'),
6891
- hint: t('outcomes.subagentsHint'),
6892
- control: h(Switch, {
6893
- checked: draft.includeSubagents === true,
6894
- label: t('outcomes.subagents'),
6895
- onChange: (next) => setField('includeSubagents', next, { immediate: true }),
6896
- }),
6897
- }),
6898
- h('div', { className: 'dshmn-item' },
6899
- h(Disclosure, {
6900
- title: t('outcomes.more'),
6901
- open: showMoreOutcomes,
6902
- onToggle: () => setShowMoreOutcomes((value) => !value),
6903
- },
6904
- h('div', { className: 'dshmn-stack', 'data-rows': 'true' },
6905
- h(Row, {
6906
- title: t('outcomes.aborted'),
6907
- hint: t('outcomes.abortedHint'),
6908
- control: h(Switch, {
6909
- checked: draft.notifyAbortedByUser === true,
6910
- label: t('outcomes.aborted'),
6911
- onChange: (next) => setField('notifyAbortedByUser', next, { immediate: true }),
6912
- }),
6913
- }),
6914
- h(Row, {
6915
- title: t('outcomes.interrupted'),
6916
- hint: t('outcomes.interruptedHint'),
6917
- control: h(Switch, {
6918
- checked: draft.notifyInterrupted === true,
6919
- label: t('outcomes.interrupted'),
6920
- onChange: (next) => setField('notifyInterrupted', next, { immediate: true }),
6921
- }),
6922
- }),
6923
- h(Row, {
6924
- title: t('outcomes.maxTokens'),
6925
- hint: t('outcomes.maxTokensHint'),
6926
- control: h(Switch, {
6927
- checked: draft.notifyMaxTokens === true,
6928
- label: t('outcomes.maxTokens'),
6929
- onChange: (next) => setField('notifyMaxTokens', next, { immediate: true }),
6930
- }),
6931
- })))),
6925
+ foldHead('dshmn-more-outcomes', t('outcomes.more'), showMoreOutcomes,
6926
+ () => setShowMoreOutcomes((value) => value !== true)),
6927
+ showMoreOutcomes !== true
6928
+ ? null
6929
+ : h('div', { className: 'dshmn-foldBody', id: 'dshmn-more-outcomes' },
6930
+ outcomeRow('notifyAbortedByUser', 'outcomes.aborted', 'outcomes.abortedHint'),
6931
+ outcomeRow('notifyInterrupted', 'outcomes.interrupted', 'outcomes.interruptedHint'),
6932
+ outcomeRow('notifyMaxTokens', 'outcomes.maxTokens', 'outcomes.maxTokensHint')))),
6932
6933
 
6933
- // ---- 可选设置(发件人、链接、排错)----
6934
- h('section', { className: 'dshmn-group', 'aria-label': t('group.optional') },
6935
- h('div', { className: 'dshmn-groupHead' },
6936
- h('h3', { className: 'dshmn-groupTitle' }, t('group.optional'))),
6937
- h('div', { className: 'dshmn-groupBody' },
6938
- h('div', { className: 'dshmn-item' },
6939
- h(Disclosure, {
6940
- title: t('group.sender'),
6941
- open: showSender,
6942
- onToggle: () => setShowSender((value) => !value),
6943
- },
6944
- h('div', { className: 'dshmn-stack' },
6945
- h('div', { className: 'dshmn-field' },
6946
- h('label', { className: 'dshmn-label' }, t('sender.listLabel')),
6947
- h('div', { className: 'dshmn-senders', role: 'radiogroup', 'aria-label': t('sender.listLabel') },
6948
- senders.length === 0
6949
- ? h('div', { className: 'dshmn-senderEmpty' }, t('sender.empty'))
6950
- : null,
6951
- ...senders.map((entry, index) => {
6952
- const isActive = entry.address === senderAddr;
6953
- return h('div', {
6954
- key: entry.address,
6955
- className: 'dshmn-sender',
6956
- 'data-active': isActive ? 'true' : 'false',
6957
- onClick: () => pickSender(entry.address),
6958
- },
6959
- h('input', {
6960
- type: 'radio',
6961
- className: 'dshmn-senderRadio',
6962
- name: 'dshmn-sender',
6963
- checked: isActive,
6964
- onChange: () => pickSender(entry.address),
6965
- 'aria-label': t('sender.useThis', { address: entry.address }),
6966
- }),
6967
- h('div', { className: 'dshmn-senderBody' },
6968
- h('div', { className: 'dshmn-senderName' }, entry.name !== '' ? entry.name : t('sender.nameUnset')),
6969
- h('div', { className: 'dshmn-senderAddr' }, entry.address)),
6970
- isActive ? h('span', { className: 'dshmn-senderBadge' }, t('sender.inUse')) : null,
6971
- h('button', {
6972
- type: 'button',
6973
- className: 'dshmn-senderIcon',
6974
- title: t('sender.edit'),
6975
- 'aria-label': `${t('sender.edit')} ${entry.address}`,
6976
- onClick: (event) => { event.stopPropagation(); openSenderDialog(index); },
6977
- }, '✎'),
6978
- h('button', {
6979
- type: 'button',
6980
- className: 'dshmn-senderIcon',
6981
- title: t('sender.remove'),
6982
- 'aria-label': `${t('sender.remove')} ${entry.address}`,
6983
- onClick: (event) => { event.stopPropagation(); removeSender(index); },
6984
- }, '×'));
6985
- }),
6986
- h('button', {
6987
- type: 'button',
6988
- className: 'dshmn-senderAdd',
6989
- onClick: () => openSenderDialog(senders.length),
6990
- }, t('sender.add'))),
6991
- h('div', { className: 'dshmn-hint' }, t('sender.listHint'))),
6992
- h(Row, {
6993
- title: t('link.enabled'),
6994
- hint: t('link.enabledHint'),
6995
- control: h(Switch, {
6996
- checked: draft.linkEnabled === true,
6997
- label: t('link.enabled'),
6998
- onChange: (next) => setField('linkEnabled', next, { immediate: true }),
6999
- }),
7000
- }),
7001
- h('div', { className: 'dshmn-field' },
7002
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-base' }, t('link.base')),
7003
- h(TextInput, {
7004
- id: 'dshmn-base',
7005
- type: 'text',
7006
- value: draft.publicBaseUrl ?? '',
7007
- placeholder: 'http://127.0.0.1:3081',
7008
- onChange: (event) => setField('publicBaseUrl', event.target.value),
7009
- onBlur: () => flush('publicBaseUrl'),
7010
- }),
7011
- h('div', { className: 'dshmn-hint' }, t('link.baseHint')))))),
7012
-
7013
- // ---- 可选:高级(排错用)----
7014
- h('div', { className: 'dshmn-item' },
7015
- h(Disclosure, {
7016
- title: t('group.advanced'),
7017
- open: showAdvanced,
7018
- onToggle: () => setShowAdvanced((value) => !value),
7019
- },
7020
- h('div', { className: 'dshmn-stack' },
7021
- h('div', { className: 'dshmn-field' },
7022
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-settle' }, t('adv.settle')),
7023
- h(NumberField, {
7024
- id: 'dshmn-settle',
7025
- value: draft.settleMs,
7026
- placeholder: '1500',
7027
- unit: t('unit.ms'),
7028
- label: t('adv.settle'),
7029
- onChange: (value) => setField('settleMs', value, { delay: NUMBER_DEBOUNCE_MS }),
7030
- onBlur: () => flush('settleMs'),
7031
- }),
7032
- h('div', { className: 'dshmn-hint' }, t('adv.settleHint'))),
7033
- h('div', { className: 'dshmn-field' },
7034
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-rate' }, t('adv.rate')),
7035
- h(NumberField, {
7036
- id: 'dshmn-rate',
7037
- value: draft.ratePerMinute,
7038
- placeholder: '10',
7039
- unit: t('unit.perMin'),
7040
- label: t('adv.rate'),
7041
- onChange: (value) => setField('ratePerMinute', value, { delay: NUMBER_DEBOUNCE_MS }),
7042
- onBlur: () => flush('ratePerMinute'),
7043
- }),
7044
- h('div', { className: 'dshmn-hint' }, t('adv.rateHint'))),
7045
- h('div', { className: 'dshmn-field' },
7046
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-pwref' }, t('adv.pwRef')),
7047
- h(TextInput, {
7048
- id: 'dshmn-pwref',
7049
- type: 'text',
7050
- value: draft.smtpPasswordRef ?? '',
7051
- onChange: (event) => setField('smtpPasswordRef', event.target.value),
7052
- onBlur: () => {
7053
- flush('smtpPasswordRef');
7054
- void refreshCredentials([draftRef.current.smtpPasswordRef]);
7055
- },
7056
- }),
7057
- h('div', { className: 'dshmn-hint' }, t('adv.pwRefHint'))),
7058
- h('div', { className: 'dshmn-field' },
7059
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-statepath' }, t('adv.statePath')),
7060
- h(TextInput, {
7061
- id: 'dshmn-statepath',
7062
- type: 'text',
7063
- value: draft.statePath ?? '',
7064
- onChange: (event) => setField('statePath', event.target.value),
7065
- onBlur: () => flush('statePath'),
7066
- }),
7067
- h('div', { className: 'dshmn-hint' }, t('adv.statePathHint')))))),
7068
- )),
7069
-
7070
- // ---- 页脚:清空覆盖是破坏性操作,所以压到最下面、只用文字链接 ----
6934
+ // ---- 页脚:清空覆盖是破坏性操作,所以压到最下面、只用一条 12/18 的文字链接 ----
6935
+ // 页脚不画线:线只长在行自己身上,页脚不是一段内容。
7071
6936
  h('div', { className: 'dshmn-foot' },
7072
6937
  h('button', {
7073
6938
  type: 'button',
@@ -7099,40 +6964,7 @@ window.__ModuleLoader__.load({
7099
6964
  closeLabel: t('cred.helpClose'),
7100
6965
  footer: h(Button, { variant: 'outline', size: 'sm', onClick: () => setHelpKind(null) }, t('cred.helpClose')),
7101
6966
  }, h('div', { className: 'dshmn-helpBody' }, t('cred.smtpHelpBody')))
7102
- : null,
7103
-
7104
- // ---- 发件人弹层(添加 / 编辑同一套)----
7105
- // 保存按钮**不置灰**:不合法时点一下就地报错,比灰着一个说不清原因的按钮好。
7106
- (typeof P.Modal === 'function' && senderEdit !== null
7107
- ? h(P.Modal, {
7108
- open: true,
7109
- onClose: closeSenderDialog,
7110
- title: t(senderEdit.index >= senders.length ? 'sender.addTitle' : 'sender.editTitle'),
7111
- closeLabel: t('sender.cancel'),
7112
- footer: h(React.Fragment, null,
7113
- h(Button, { variant: 'outline', size: 'sm', onClick: closeSenderDialog }, t('sender.cancel')),
7114
- h(Button, { variant: 'primary', size: 'sm', onClick: saveSenderDialog }, t('sender.save'))),
7115
- },
7116
- h('div', { className: 'dshmn-field' },
7117
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-sender-name' }, t('sender.name')),
7118
- h(TextInput, {
7119
- id: 'dshmn-sender-name',
7120
- type: 'text',
7121
- value: senderEdit.name,
7122
- onChange: (event) => setSenderEdit({ ...senderEdit, name: event.target.value }),
7123
- }),
7124
- h('div', { className: 'dshmn-hint' }, t('sender.nameHint'))),
7125
- h('div', { className: 'dshmn-field' },
7126
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-sender-addr' }, t('sender.address')),
7127
- h(TextInput, {
7128
- id: 'dshmn-sender-addr',
7129
- type: 'text',
7130
- value: senderEdit.address,
7131
- onChange: (event) => setSenderEdit({ ...senderEdit, address: event.target.value }),
7132
- }),
7133
- h('div', { className: 'dshmn-hint' }, t('sender.addressHint'))),
7134
- senderErr !== '' ? h('div', { className: 'dshmn-error', role: 'alert' }, senderErr) : null)
7135
- : null))));
6967
+ : null));
7136
6968
  }
7137
6969
 
7138
6970
  /**
@@ -7146,7 +6978,29 @@ window.__ModuleLoader__.load({
7146
6978
  const message = typeof error === 'string'
7147
6979
  ? error
7148
6980
  : (error && typeof error === 'object' && typeof error.message === 'string' ? error.message : JSON.stringify(error ?? response));
7149
- throw new Error(message);
6981
+ const failure = new Error(message);
6982
+ // RemoteError 的真实形状(@deepseek-ai/dsh-typert-protocol):一个真 Error,带 code 与 details。
6983
+ // **一定要把 code 带出来**:冲突恢复只认 code,把它丢掉就只剩一句人话,
6984
+ // 重试必然拿着旧 revision 再撞一次,用户只能关掉设置页再进来。
6985
+ if (error && typeof error === 'object') {
6986
+ if (typeof error.code === 'string') failure.code = error.code;
6987
+ if (error.details !== undefined) failure.details = error.details;
6988
+ }
6989
+ throw failure;
6990
+ }
6991
+
6992
+ /**
6993
+ * 这次写失败,是不是「我读到之后别人又改了」?
6994
+ *
6995
+ * 判别一律靠 code(settings 控制器对 provider 的拒绝只分两档):
6996
+ * - settings/conflict:revision 被别处推走了,正确反应是**重读 + 重放**;
6997
+ * - settings/rejected:请求本身不成立(只读 provider / schema 校验 / 存储故障),重试没用。
6998
+ * @param {unknown} error 捕获到的异常
6999
+ * @returns {boolean} true 表示是 revision 冲突
7000
+ */
7001
+ function isConflict(error) {
7002
+ const code = typeof error?.code === 'string' ? error.code : '';
7003
+ return code === 'settings/conflict' || code === 'conflict';
7150
7004
  }
7151
7005
 
7152
7006
  /**
@@ -7154,8 +7008,8 @@ window.__ModuleLoader__.load({
7154
7008
  *
7155
7009
  * 走 connection 的通用 RPC 而不是挂 remote 命名空间:本插件的 client 半是手写包,
7156
7010
  * 没有生成出来的 remote 声明,而 `/api` + `<namespace>/<method>` 是同一套网关,
7157
- * 参数与清单里声明的 parameters 必须完全一致(这里两个方法都是零参数)。
7158
- * @param {'test'|'state'} method 方法名
7011
+ * 参数与清单里声明的 parameters 必须完全一致(test 是零参数)。
7012
+ * @param {'test'} method 方法名
7159
7013
  * @returns {Promise<unknown>} Remote 的 `{ ok, value }` 信封
7160
7014
  */
7161
7015
  function callRemote(method) {
@@ -7191,7 +7045,7 @@ window.__ModuleLoader__.load({
7191
7045
  }
7192
7046
 
7193
7047
  // Host 动作走 connection 的通用 RPC:`/api` + `<namespace>/<method>`。
7194
- // 参数必须与清单声明的 parameters 完全一致——test/state 都是零参数,所以是空对象。
7048
+ // 参数必须与清单声明的 parameters 完全一致——test 是零参数,所以是空对象。
7195
7049
  try {
7196
7050
  ctx.inject(['connection'], (scoped) => {
7197
7051
  const rpc = scoped.connection?.rpc;
@@ -7205,7 +7059,7 @@ window.__ModuleLoader__.load({
7205
7059
 
7206
7060
  try {
7207
7061
  ctx.inject(['locale'], (scoped) => {
7208
- ctx.effect(() => scoped.locale.register(NS, { zh: ZH, en: EN }), 'dsh-notify-p: dictionaries');
7062
+ ctx.effect(() => scoped.locale.register(LOCALE_NS, { zh: ZH, en: EN }), 'dsh-notify-p: dictionaries');
7209
7063
  i18n.locale = scoped.locale;
7210
7064
  console.info('[dsh-notify-p] 文案字典已注册 / locale dictionaries registered');
7211
7065
  });
@@ -7230,7 +7084,7 @@ window.__ModuleLoader__.load({
7230
7084
  }
7231
7085
  },
7232
7086
  /** 给离线单测用的接线口(浏览器运行时不会读它)。 */
7233
- __internals: { parseRecipients, migrateSenders, activeSenderAddress, senderIssue, senderCommit, senderRemove, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, relativeTime, PRESETS, ZH, EN, CSS, fmt, i18n },
7087
+ __internals: { parseRecipients, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, PRESETS, ZH, EN, CSS, fmt, diffOps, i18n },
7234
7088
  };
7235
7089
  },
7236
7090
  });
@@ -8172,6 +8026,1899 @@ window.__ModuleLoader__.load({
8172
8026
  }
8173
8027
  //#endregion
8174
8028
 
8029
+ //#region workspace rows
8030
+ /**
8031
+ * Workspace-sidebar tags, whole-group hiding, and the "Workspaces" settings table.
8032
+ *
8033
+ * Why the merge glue: the three bundled parts are byte-locked by
8034
+ * test/merged-client.test.mjs, and the shipped workspace browser is a single
8035
+ * opaque seat (sidebar.workspaces, kind single) with no per-row children, so a
8036
+ * tag can only be injected and hiding can only be a style rule. Both are built
8037
+ * to fail silently and never throw into the host's render tree.
8038
+ *
8039
+ * Host contract (read-only reference: @deepseek-ai/dsh-client-ui-workspace):
8040
+ * [data-slot="sidebar.workspaces"]
8041
+ * div.groupSection one per workspace; children nest
8042
+ * div[role="treeitem"][data-row-key="workspace:<id>"]
8043
+ * span.projectText > span.title
8044
+ * span.rowActions hover actions ("⋯" + new session)
8045
+ * div[role="group"] > div.groupSection child workspaces
8046
+ * div[role="treeitem"][data-row-key="session:<id>"][aria-selected]
8047
+ * button[data-row-key="overflow:<key>"]
8048
+ *
8049
+ * Tag placement is row-level: the badge is a direct child of the workspace
8050
+ * row, immediately before span.rowActions (or at the row's end when the host
8051
+ * renders no actions). margin-left:auto pushes it to the right edge; the
8052
+ * row's own gap supplies the minimum spacing from the name. The idempotency
8053
+ * key is data-lookout-ws-tag="<workspaceId>".
8054
+ *
8055
+ * Hiding is one generated rule per hidden workspace, with every selector
8056
+ * scoped to the group wrapper one/two levels above the keyed row (the shipped
8057
+ * HoverCard wraps its anchor in a <span>):
8058
+ * div:has(> [data-row-key="workspace:<id>"]),
8059
+ * div:has(>span>[data-row-key="workspace:<id>"]), {display:none!important}
8060
+ * plus .*groupSection variants of the same two shapes. :has() hides the whole
8061
+ * group (workspace row + its sessions + the "expand" button) without touching
8062
+ * React. No rule is ever generated for a workspace that contains the CURRENT
8063
+ * session, nor for any of its ancestors.
8064
+ *
8065
+ * Preferences live on the Host (iconCustom/getWorkspacePrefs /
8066
+ * setWorkspacePrefs, full-document replace) with a localStorage mirror used
8067
+ * first on load and kept as the fallback when RPC is unavailable. A save
8068
+ * failure is reported once, never per keystroke.
8069
+ *
8070
+ * Breaker (the row-menu pattern): three consecutive scans that place nothing
8071
+ * while there IS work to place stop the feature for the page, warn once,
8072
+ * explain once, and can be reset from the settings switch.
8073
+ */
8074
+ /**
8075
+ * Own locale namespace. locale.register() THROWS when a namespace already owns a
8076
+ * locale, and the file-row menu above already registers zh/en under
8077
+ * "dsh-lookout"; sharing it would silently drop these dictionaries.
8078
+ */
8079
+ const WS_ROWS_NS = "dsh-lookout-workspaces";
8080
+ const WS_TAG_ATTR = "data-lookout-ws-tag";
8081
+ const WS_TAG_TEXT_ATTR = "data-lookout-ws-tag-text";
8082
+ const WS_TOAST_ATTR = "data-lookout-ws-toast";
8083
+ const WS_STYLE_KEY = "lookout-workspace-rows";
8084
+ const WS_HIDE_STYLE_KEY = "lookout-workspace-hide";
8085
+ const WS_PREFS_LOCAL_KEY = "dsh-lookout.workspacePrefs";
8086
+ const WS_ROWS_ENABLED_KEY = "dsh-lookout:workspace-rows:enabled";
8087
+ const WS_ROWS_DISMISSED_KEY = "dsh-lookout:workspace-rows:gave-up-dismissed";
8088
+ const WS_MAX_FAILURES = 3;
8089
+ const WS_SCAN_DEBOUNCE = 200;
8090
+ const WS_QUIET_MS = 250;
8091
+ const WS_TOAST_MS = 3000;
8092
+ const WS_SAVE_DEBOUNCE = 250;
8093
+ const WS_TAG_MAX = 6;
8094
+ /** Pin replay: coalesce list churn, then move only what is out of place. */
8095
+ const WS_PIN_DEBOUNCE = 150;
8096
+ const WS_PIN_MAX_ROUNDS = 5;
8097
+ const WS_PIN_MAX_MOVES = 32;
8098
+ /** A refused reorder backs off, then gives up: no RPC hammering on a broken host. */
8099
+ const WS_PIN_RETRY_MS = 2000;
8100
+ const WS_PIN_MAX_FAILURES = 3;
8101
+ /** Same eight names the Host normalizes to; anything else falls back to gray. */
8102
+ const WS_COLORS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "gray"];
8103
+ const WS_COLOR_DEFAULT = "gray";
8104
+ /**
8105
+ * Literal colours for the eight tag dots. Each capsule keeps the host's grey
8106
+ * fill and secondary text colour and draws only its dot in the accent hue;
8107
+ * the rest of the row keeps --dsw-alias-* tokens so light/dark still follows
8108
+ * the host; these hues have no token equivalent.
8109
+ */
8110
+ const WS_COLOR_FALLBACK = {
8111
+ red: "#e5484d",
8112
+ orange: "#f76b15",
8113
+ yellow: "#d9a123",
8114
+ green: "#30a46c",
8115
+ cyan: "#00a2c7",
8116
+ blue: "#3e63dd",
8117
+ purple: "#8e4ec6",
8118
+ gray: "#8b8d98"
8119
+ };
8120
+ const WS_ROW_KEY_ATTR = "data-row-key";
8121
+ const WS_WORKSPACE_PREFIX = "workspace:";
8122
+ const WS_SESSION_PREFIX = "session:";
8123
+ const WS_CONTAINER_SELECTOR = '[data-slot="sidebar.workspaces"]';
8124
+ const WS_CONTAINER_FALLBACK = '[data-slot="sidebar"]';
8125
+ /** Workspace ids are uuid-ish; refuse anything a CSS attribute string cannot carry safely. */
8126
+ const WS_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
8127
+ const WS_HIDE_CSS_HEADER = "/* dsh-lookout: hidden workspaces (regenerated on every paint) */";
8128
+
8129
+ const WS_ROWS_CSS = [
8130
+ ".dsh-lookout-ws-tag{display:inline-flex;align-items:center;gap:4px;flex:none;max-width:9em;margin-left:auto;padding:0 6px;height:16px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;pointer-events:none;overflow:hidden;white-space:nowrap;}",
8131
+ ".dsh-lookout-ws-tag-dot{flex:none;width:6px;height:6px;border-radius:50%;background:" + WS_COLOR_FALLBACK.gray + ";}",
8132
+ ".dsh-lookout-ws-tag-text{min-width:0;overflow:hidden;text-overflow:ellipsis;}",
8133
+ ".dsh-lookout-ws-toast{position:fixed;left:50%;bottom:24px;z-index:90;display:flex;align-items:center;gap:10px;max-width:min(92vw,560px);padding:9px 12px;transform:translateX(-50%);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-label-primary-inverted);font-size:12px;line-height:1.5;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-drop);}",
8134
+ ".dsh-lookout-ws-toast button{flex:none;padding:3px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer;}"
8135
+ ].concat(WS_COLORS.map((name) => '.dsh-lookout-ws-tag[data-color="' + name + '"] .dsh-lookout-ws-tag-dot{background:' + WS_COLOR_FALLBACK[name] + ";}")).join("\n");
8136
+
8137
+ /** Chinese fallback for every translate call, also used when locale is unavailable. */
8138
+ const WS_ROWS_ZH = {
8139
+ nav: "工作区管理",
8140
+ toggleLabel: "在侧栏显示工作区标签,并按设置隐藏工作区",
8141
+ toggleHint: "标签只显示在侧栏工作区行右侧(悬停时出现在「⋯」按钮左边);隐藏是把整个工作区收起(连同它下面的会话和「展开」按钮)。当前会话所在的工作区永远不会被隐藏。这些偏好存在宿主侧,宿主升级后若定位不到工作区行,只会不显示,侧栏本身不受影响。",
8142
+ colName: "工作区",
8143
+ colTag: "标签",
8144
+ colColor: "颜色",
8145
+ colPin: "置顶",
8146
+ colHide: "隐藏",
8147
+ tagPlaceholder: "最多 6 字",
8148
+ pinnedHint: "置顶的工作区排在列表最前;取消置顶会尽量放回原来的位置。子工作区不单独置顶,跟着它的上级工作区排列。",
8149
+ pinChildHint: "子工作区不单独置顶,跟着上级工作区排列。",
8150
+ pinUnavailable: "拿不到工作区服务,置顶只会保存偏好,不会调整顺序。",
8151
+ pinFailed: "工作区置顶没有生效,列表可能保持原样。",
8152
+ cleanup: "清理失效项",
8153
+ cleaned: "已清理 {n} 个失效项",
8154
+ cleanNone: "没有可清理的失效项",
8155
+ empty: "还没有工作区。",
8156
+ colorRed: "红",
8157
+ colorOrange: "橙",
8158
+ colorYellow: "黄",
8159
+ colorGreen: "绿",
8160
+ colorCyan: "青",
8161
+ colorBlue: "蓝",
8162
+ colorPurple: "紫",
8163
+ colorGray: "灰",
8164
+ givingUp: "工作区标签连续注入失败,已自动停止;侧栏本身不受影响。",
8165
+ dismiss: "不再提示",
8166
+ saveFailed: "工作区偏好保存失败,改动只保留在本机。"
8167
+ };
8168
+ const WS_ROWS_EN = {
8169
+ nav: "Workspaces",
8170
+ toggleLabel: "Show workspace tags in the sidebar and hide workspaces as configured",
8171
+ toggleHint: "A tag sits at the right end of the workspace row, just left of the hover actions; hiding collapses a whole workspace group, including its sessions and the expand button. The workspace that holds the current session is never hidden. Preferences live on the host, so a host upgrade can at worst stop the tags from appearing \u2014 the sidebar itself is unaffected.",
8172
+ colName: "Workspace",
8173
+ colTag: "Tag",
8174
+ colColor: "Colour",
8175
+ colPin: "Pin",
8176
+ colHide: "Hide",
8177
+ tagPlaceholder: "Up to 6 characters",
8178
+ pinnedHint: "Pinned workspaces sit at the top of the list; unpinning puts one back where it was. Child workspaces are not pinned on their own \u2014 they follow their parent.",
8179
+ pinChildHint: "Child workspaces are not pinned on their own; they follow their parent workspace.",
8180
+ pinUnavailable: "The workspace service is unavailable, so pinning only saves the preference and never reorders.",
8181
+ pinFailed: "Pinning did not take effect; the list may look unchanged.",
8182
+ cleanup: "Remove stale entries",
8183
+ cleaned: "Removed {n} stale entries",
8184
+ cleanNone: "No stale entries to remove",
8185
+ empty: "No workspaces yet.",
8186
+ colorRed: "Red",
8187
+ colorOrange: "Orange",
8188
+ colorYellow: "Yellow",
8189
+ colorGreen: "Green",
8190
+ colorCyan: "Cyan",
8191
+ colorBlue: "Blue",
8192
+ colorPurple: "Purple",
8193
+ colorGray: "Gray",
8194
+ givingUp: "The workspace tags could not be injected repeatedly and stopped; the sidebar itself is unaffected.",
8195
+ dismiss: "Do not show again",
8196
+ saveFailed: "Workspace preferences could not be saved; changes stay in this browser only."
8197
+ };
8198
+
8199
+ const WS_STYLE_TITLE = { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)", marginBottom: 8 };
8200
+ const WS_STYLE_HINT = { marginTop: 6, fontSize: 12, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary)" };
8201
+ const WS_STYLE_TABLE = { borderCollapse: "collapse", fontSize: 12, color: "var(--dsw-alias-label-primary)" };
8202
+ const WS_STYLE_TH = { padding: "4px 8px", textAlign: "left", fontWeight: 500, color: "var(--dsw-alias-label-tertiary)", borderBottom: "1px solid var(--dsw-alias-border-l2)" };
8203
+ const WS_STYLE_TD = { padding: "4px 8px", borderBottom: "1px solid var(--dsw-alias-border-l2)", verticalAlign: "middle" };
8204
+ const WS_STYLE_ACTIONS = { display: "flex", alignItems: "center", gap: 12, marginTop: 10 };
8205
+ const WS_STYLE_TOGGLE = { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" };
8206
+
8207
+ function wsRowsFallbackText(key, params) {
8208
+ const template = typeof WS_ROWS_ZH[key] === "string" ? WS_ROWS_ZH[key] : key;
8209
+ if (params === null || params === undefined || typeof params !== "object") return template;
8210
+ let text = template;
8211
+ for (const name of Object.keys(params)) text = text.split("{" + name + "}").join(String(params[name]));
8212
+ return text;
8213
+ }
8214
+
8215
+ /** Tag normalization matches the Host: drop control characters, trim, cap at 6 Unicode characters. */
8216
+ function wsRowsNormalizeTag(input) {
8217
+ if (typeof input !== "string") return "";
8218
+ const cleaned = input.replace(/\p{Cc}/gu, "").trim();
8219
+ return [...cleaned].slice(0, WS_TAG_MAX).join("");
8220
+ }
8221
+
8222
+ function wsRowsNormalizeColor(input) {
8223
+ return WS_COLORS.indexOf(input) >= 0 ? input : WS_COLOR_DEFAULT;
8224
+ }
8225
+
8226
+ function wsRowsValidId(input) {
8227
+ return typeof input === "string" && WS_ID_PATTERN.test(input);
8228
+ }
8229
+
8230
+ function wsRowsEmptyDoc() {
8231
+ return { version: 1, workspaces: {} };
8232
+ }
8233
+
8234
+ function wsRowsNormalizeEntry(input) {
8235
+ const source = input !== null && typeof input === "object" && !Array.isArray(input) ? input : {};
8236
+ return {
8237
+ tag: wsRowsNormalizeTag(source.tag),
8238
+ color: wsRowsNormalizeColor(source.color),
8239
+ pinned: source.pinned === true,
8240
+ hidden: source.hidden === true,
8241
+ pinAnchor: typeof source.pinAnchor === "string" ? source.pinAnchor : null
8242
+ };
8243
+ }
8244
+
8245
+ /** Never throws: a corrupt cache or a bad Host response degrades to the empty document. */
8246
+ function wsRowsNormalizeDoc(input) {
8247
+ const doc = wsRowsEmptyDoc();
8248
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return doc;
8249
+ const workspaces = input.workspaces;
8250
+ if (workspaces === null || typeof workspaces !== "object" || Array.isArray(workspaces)) return doc;
8251
+ for (const [id, entry] of Object.entries(workspaces)) {
8252
+ if (!wsRowsValidId(id)) continue;
8253
+ if (id === "__proto__" || id === "constructor" || id === "prototype") continue;
8254
+ doc.workspaces[id] = wsRowsNormalizeEntry(entry);
8255
+ }
8256
+ return doc;
8257
+ }
8258
+
8259
+ /** An entry that carries no preference at all is dropped instead of being kept as junk. */
8260
+ function wsRowsIsDefaultEntry(entry) {
8261
+ return entry.tag === "" && entry.pinned !== true && entry.hidden !== true && entry.pinAnchor === null;
8262
+ }
8263
+
8264
+ function wsRowsReadFlag(key, fallback) {
8265
+ try {
8266
+ const raw = window.localStorage.getItem(key);
8267
+ if (raw === "1") return true;
8268
+ if (raw === "0") return false;
8269
+ return fallback;
8270
+ } catch { return fallback; }
8271
+ }
8272
+
8273
+ function wsRowsWriteFlag(key, value) {
8274
+ try { window.localStorage.setItem(key, value ? "1" : "0"); } catch { /* advisory */ }
8275
+ }
8276
+
8277
+ function wsRowsReadLocalPrefs() {
8278
+ try {
8279
+ const raw = window.localStorage.getItem(WS_PREFS_LOCAL_KEY);
8280
+ if (typeof raw !== "string" || raw === "") return wsRowsEmptyDoc();
8281
+ return wsRowsNormalizeDoc(JSON.parse(raw));
8282
+ } catch { return wsRowsEmptyDoc(); }
8283
+ }
8284
+
8285
+ function wsRowsWriteLocalPrefs(doc) {
8286
+ try { window.localStorage.setItem(WS_PREFS_LOCAL_KEY, JSON.stringify(doc)); } catch { /* advisory */ }
8287
+ }
8288
+
8289
+ /** One warning per page at most, and only for a real degradation. */
8290
+ let wsRowsWarned = false;
8291
+ function wsRowsWarn(message, error) {
8292
+ if (wsRowsWarned) return;
8293
+ wsRowsWarned = true;
8294
+ try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
8295
+ }
8296
+
8297
+ /** The table's row list: live workspaces first, the stored ids when the host list is unavailable. */
8298
+ function wsRowsTableRows(prefs, items) {
8299
+ const doc = wsRowsNormalizeDoc(prefs);
8300
+ const rows = [];
8301
+ const seen = new Set();
8302
+ const roots = wsPinRootIds(items);
8303
+ if (Array.isArray(items)) {
8304
+ for (const item of items) {
8305
+ if (item === null || typeof item !== "object") continue;
8306
+ const id = typeof item.workspaceId === "string" ? item.workspaceId : (typeof item.id === "string" ? item.id : "");
8307
+ if (!wsRowsValidId(id) || seen.has(id)) continue;
8308
+ seen.add(id);
8309
+ const title = typeof item.title === "string" && item.title.trim() !== ""
8310
+ ? item.title
8311
+ : (typeof item.name === "string" && item.name.trim() !== "" ? item.name : id);
8312
+ rows.push({
8313
+ id,
8314
+ name: title,
8315
+ child: items.length > 0 && !roots.has(id),
8316
+ entry: doc.workspaces[id] !== undefined ? doc.workspaces[id] : wsRowsNormalizeEntry(null)
8317
+ });
8318
+ }
8319
+ }
8320
+ if (rows.length === 0) {
8321
+ for (const id of Object.keys(doc.workspaces)) rows.push({ id, name: id, child: false, entry: doc.workspaces[id] });
8322
+ }
8323
+ return rows;
8324
+ }
8325
+
8326
+ function wsRowsKnownIds(items) {
8327
+ const ids = [];
8328
+ if (!Array.isArray(items)) return ids;
8329
+ for (const item of items) {
8330
+ if (item === null || typeof item !== "object") continue;
8331
+ const id = typeof item.workspaceId === "string" ? item.workspaceId : (typeof item.id === "string" ? item.id : "");
8332
+ if (wsRowsValidId(id)) ids.push(id);
8333
+ }
8334
+ return ids;
8335
+ }
8336
+
8337
+ /** Path spelling used by the shipped workspace list: forward slashes, no trailing slash. */
8338
+ function wsPinNormalizePath(value) {
8339
+ if (typeof value !== "string") return "";
8340
+ const trimmed = value.trim();
8341
+ if (trimmed === "") return "";
8342
+ const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
8343
+ return normalized === "" ? "/" : normalized;
8344
+ }
8345
+
8346
+ /**
8347
+ * Workspaces that are roots rather than children: a path sitting under
8348
+ * another registered workspace's path is a child and never pins on its own.
8349
+ * An item without a usable path counts as a root (topology unknown, never
8350
+ * guess); a stored pin on a child is ignored but never deleted.
8351
+ * @returns Set of root workspace ids.
8352
+ */
8353
+ function wsPinRootIds(items) {
8354
+ const entries = [];
8355
+ if (Array.isArray(items)) {
8356
+ for (const item of items) {
8357
+ if (item === null || typeof item !== "object") continue;
8358
+ const id = typeof item.workspaceId === "string" && item.workspaceId !== ""
8359
+ ? item.workspaceId
8360
+ : (typeof item.id === "string" ? item.id : "");
8361
+ if (!wsRowsValidId(id)) continue;
8362
+ entries.push({ id, path: wsPinNormalizePath(item.path) });
8363
+ }
8364
+ }
8365
+ const roots = new Set();
8366
+ for (const entry of entries) {
8367
+ let child = false;
8368
+ if (entry.path !== "") {
8369
+ for (const parent of entries) {
8370
+ if (parent === entry || parent.path === "") continue;
8371
+ const under = parent.path === "/"
8372
+ ? entry.path.charAt(0) === "/"
8373
+ : entry.path.indexOf(parent.path + "/") === 0;
8374
+ if (under) { child = true; break; }
8375
+ }
8376
+ }
8377
+ if (!child) roots.add(entry.id);
8378
+ }
8379
+ return roots;
8380
+ }
8381
+
8382
+ /**
8383
+ * The minimum moves that put every pinned ROOT workspace at the front while
8384
+ * keeping their current relative order. Returns null when the list already
8385
+ * satisfies that, so a settled list makes ZERO insertBefore calls.
8386
+ *
8387
+ * Only the pinned workspaces that are not already in the correct prefix move;
8388
+ * each is inserted before the same first unpinned workspace, which keeps the
8389
+ * moved block in order without reading the list between awaits.
8390
+ * @param prefsDoc - the preference document.
8391
+ * @param items - the live workspace list (workspaceId / path / title).
8392
+ * @returns { steps, key } or null.
8393
+ */
8394
+ function wsPinPlan(prefsDoc, items) {
8395
+ const doc = wsRowsNormalizeDoc(prefsDoc);
8396
+ const ids = [];
8397
+ if (Array.isArray(items)) {
8398
+ for (const item of items) {
8399
+ if (item === null || typeof item !== "object") continue;
8400
+ const id = typeof item.workspaceId === "string" && item.workspaceId !== ""
8401
+ ? item.workspaceId
8402
+ : (typeof item.id === "string" ? item.id : "");
8403
+ if (wsRowsValidId(id) && ids.indexOf(id) < 0) ids.push(id);
8404
+ }
8405
+ }
8406
+ if (ids.length === 0) return null;
8407
+ const roots = wsPinRootIds(items);
8408
+ const pinned = [];
8409
+ for (const id of ids) {
8410
+ const entry = doc.workspaces[id];
8411
+ if (entry === undefined || entry.pinned !== true) continue;
8412
+ if (!roots.has(id)) continue; // a child's historical pin is ignored, not cleared
8413
+ pinned.push(id);
8414
+ }
8415
+ if (pinned.length === 0) return null;
8416
+ let placed = 0;
8417
+ while (placed < pinned.length && ids[placed] === pinned[placed]) placed++;
8418
+ if (placed >= pinned.length) return null;
8419
+ // ids[placed] is non-pinned by construction, so it is the stable anchor.
8420
+ const before = ids[placed];
8421
+ const steps = [];
8422
+ for (let index = placed; index < pinned.length; index++) steps.push({ id: pinned[index], before });
8423
+ return { steps, key: steps.map((step) => step.id + ">" + step.before).join("|") };
8424
+ }
8425
+
8426
+ /** Escape a value for a double-quoted CSS string; control characters are quoted numerically. */
8427
+ function wsRowsCssString(value) {
8428
+ let out = "";
8429
+ for (const character of String(value)) {
8430
+ const code = character.codePointAt(0);
8431
+ if (character === '"' || character === "\\") out += "\\" + character;
8432
+ else if (code < 0x20 || code === 0x7f) out += "\\" + code.toString(16) + " ";
8433
+ else out += character;
8434
+ }
8435
+ return out;
8436
+ }
8437
+
8438
+ function wsRowsStart(ctx, require) {
8439
+ let controller = null;
8440
+ try {
8441
+ controller = wsRowsInstall(ctx, require);
8442
+ } catch (error) {
8443
+ wsRowsWarn("工作区标签初始化失败,已跳过(不影响宿主):", error);
8444
+ return;
8445
+ }
8446
+ try {
8447
+ if (ctx !== null && ctx !== undefined && typeof ctx.effect === "function") {
8448
+ ctx.effect(() => () => { try { controller.dispose(); } catch { /* best effort */ } }, "dsh-lookout: workspace rows");
8449
+ } else if (ctx !== null && ctx !== undefined && typeof ctx.on === "function") {
8450
+ ctx.on("dispose", () => { try { controller.dispose(); } catch { /* best effort */ } });
8451
+ }
8452
+ } catch (error) {
8453
+ wsRowsWarn("工作区标签挂到生命周期失败:", error);
8454
+ }
8455
+ }
8456
+
8457
+ /**
8458
+ * Install tags + hiding + the settings table, and return the controller.
8459
+ * Every DOM lookup is guarded: a host upgrade can only cost the tags.
8460
+ * @returns {object} controller: dispose / snapshot / subscribe / updateEntry /
8461
+ * cleanupMissing / paintNow / domWorkspaceIds / setEnabled / isEnabled.
8462
+ */
8463
+ function wsRowsInstall(ctx, require) {
8464
+ let disposed = false;
8465
+ let enabled = wsRowsReadFlag(WS_ROWS_ENABLED_KEY, true);
8466
+ let prefs = wsRowsReadLocalPrefs();
8467
+ let localDirty = false;
8468
+ let translate = wsRowsFallbackText;
8469
+ let observer = null;
8470
+ let observerRoot = null;
8471
+ let retryTimer = 0;
8472
+ let scanTimer = 0;
8473
+ let saveTimer = 0;
8474
+ let painting = false;
8475
+ let paintedAt = 0;
8476
+ let failures = 0;
8477
+ let givenUp = false;
8478
+ let saveFailedOnce = false;
8479
+ let toast = null;
8480
+ let toastTimer = 0;
8481
+ const prefListeners = new Set();
8482
+ // ---- pinning --------------------------------------------------------
8483
+ // The workspace service arrives late (ctx.inject) and is optional: tags,
8484
+ // hiding and the preferences table never wait for it.
8485
+ let pinService = null;
8486
+ let pinUnsubscribe = null;
8487
+ let pinTimer = 0;
8488
+ let pinRunning = false;
8489
+ let pinDirty = false;
8490
+ let pinWarned = false;
8491
+ let pinFailedOnce = false;
8492
+ let pinFailures = 0;
8493
+ let pinSuspended = false;
8494
+ let pinCooldownUntil = 0;
8495
+
8496
+ // ---- locale ---------------------------------------------------------
8497
+ try {
8498
+ const locale = ctx !== null && ctx !== undefined ? ctx.locale : null;
8499
+ if (locale !== null && locale !== undefined && typeof locale.register === "function") {
8500
+ const register = () => locale.register(WS_ROWS_NS, { zh: WS_ROWS_ZH, en: WS_ROWS_EN });
8501
+ if (typeof ctx.effect === "function") ctx.effect(register, "dsh-lookout: workspace rows dictionaries");
8502
+ else register();
8503
+ }
8504
+ if (locale !== null && locale !== undefined && typeof locale.bind === "function") {
8505
+ const bound = locale.bind(WS_ROWS_NS);
8506
+ translate = (key, params) => {
8507
+ let text = key;
8508
+ try { text = bound(key, params); } catch { text = key; }
8509
+ return typeof text === "string" && text !== key ? text : wsRowsFallbackText(key, params);
8510
+ };
8511
+ }
8512
+ } catch (error) { /* Chinese fallback is already in place */ }
8513
+
8514
+ const api = {
8515
+ snapshot: snapshot,
8516
+ subscribe: subscribePrefs,
8517
+ updateEntry: updateEntry,
8518
+ cleanupMissing: cleanupMissing,
8519
+ paintNow: paint,
8520
+ pinNow: pinNow,
8521
+ domWorkspaceIds: domWorkspaceIds,
8522
+ setEnabled: setEnabled,
8523
+ isEnabled: () => enabled === true
8524
+ };
8525
+
8526
+ // ---- settings page --------------------------------------------------
8527
+ // Own section (order 56, right after the file-row menu at 55): the three
8528
+ // bundled parts own their pages and are byte-locked.
8529
+ try {
8530
+ const React = require("react");
8531
+ if (React !== null && typeof React.createElement === "function" && typeof React.useState === "function") {
8532
+ function WorkspaceRowsSection(props) {
8533
+ const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
8534
+ const [view, setView] = React.useState(() => prefs);
8535
+ const [on, setOn] = React.useState(() => enabled);
8536
+ const [notice, setNotice] = React.useState("");
8537
+ React.useEffect(() => subscribePrefs(setView), []);
8538
+ const useWorkspaces = props !== null && props !== undefined ? props.useWorkspaces : null;
8539
+ const snapshotState = typeof useWorkspaces === "function" ? useWorkspaces((state) => state) : undefined;
8540
+ const items = snapshotState !== null && typeof snapshotState === "object" && Array.isArray(snapshotState.items) ? snapshotState.items : [];
8541
+ const rows = wsRowsTableRows(view, items);
8542
+ const commit = (id, patch) => { updateEntry(id, patch); };
8543
+ const onCleanup = () => {
8544
+ const live = wsRowsKnownIds(items);
8545
+ const ids = live.length > 0 ? live : domWorkspaceIds();
8546
+ const removed = cleanupMissing(ids);
8547
+ setNotice(removed > 0 ? label("cleaned", { n: removed }) : label("cleanNone"));
8548
+ };
8549
+ const header = React.createElement("thead", null,
8550
+ React.createElement("tr", null,
8551
+ ["colName", "colTag", "colColor", "colPin", "colHide"].map((key) =>
8552
+ React.createElement("th", { key, style: WS_STYLE_TH }, label(key)))));
8553
+ const body = React.createElement("tbody", null,
8554
+ rows.map((row) => React.createElement("tr", { key: row.id, "data-lookout-ws-row": row.id },
8555
+ React.createElement("td", { style: WS_STYLE_TD }, row.name),
8556
+ React.createElement("td", { style: WS_STYLE_TD },
8557
+ React.createElement("input", {
8558
+ "data-lookout-ws-field": "tag",
8559
+ "data-lookout-ws-id": row.id,
8560
+ type: "text",
8561
+ maxLength: WS_TAG_MAX,
8562
+ value: row.entry.tag,
8563
+ placeholder: label("tagPlaceholder"),
8564
+ style: { width: 96 },
8565
+ onChange: (event) => commit(row.id, { tag: event.target.value })
8566
+ })),
8567
+ React.createElement("td", { style: WS_STYLE_TD },
8568
+ React.createElement("select", {
8569
+ "data-lookout-ws-field": "color",
8570
+ "data-lookout-ws-id": row.id,
8571
+ value: row.entry.color,
8572
+ onChange: (event) => commit(row.id, { color: event.target.value })
8573
+ }, WS_COLORS.map((name) =>
8574
+ React.createElement("option", { key: name, value: name },
8575
+ label("color" + name.charAt(0).toUpperCase() + name.slice(1)))))),
8576
+ React.createElement("td", { style: WS_STYLE_TD },
8577
+ React.createElement("input", {
8578
+ "data-lookout-ws-field": "pinned",
8579
+ "data-lookout-ws-id": row.id,
8580
+ type: "checkbox",
8581
+ checked: row.entry.pinned === true,
8582
+ disabled: row.child === true,
8583
+ title: row.child === true ? label("pinChildHint") : undefined,
8584
+ onChange: (event) => commit(row.id, { pinned: event.target.checked === true })
8585
+ })),
8586
+ React.createElement("td", { style: WS_STYLE_TD },
8587
+ React.createElement("input", {
8588
+ "data-lookout-ws-field": "hidden",
8589
+ "data-lookout-ws-id": row.id,
8590
+ type: "checkbox",
8591
+ checked: row.entry.hidden === true,
8592
+ onChange: (event) => commit(row.id, { hidden: event.target.checked === true })
8593
+ })))));
8594
+ return React.createElement("div", { "data-lookout-settings": "workspaces" },
8595
+ React.createElement("div", { style: WS_STYLE_TITLE }, label("nav")),
8596
+ rows.length === 0
8597
+ ? React.createElement("div", { style: WS_STYLE_HINT }, label("empty"))
8598
+ : React.createElement("table", { style: WS_STYLE_TABLE, "data-lookout-ws-table": "" }, header, body),
8599
+ rows.some((row) => row.child === true)
8600
+ ? React.createElement("div", { style: WS_STYLE_HINT }, label("pinChildHint"))
8601
+ : null,
8602
+ React.createElement("div", { style: WS_STYLE_ACTIONS },
8603
+ React.createElement("button", {
8604
+ type: "button",
8605
+ "data-lookout-ws-cleanup": "",
8606
+ onClick: onCleanup
8607
+ }, label("cleanup")),
8608
+ React.createElement("label", { style: WS_STYLE_TOGGLE },
8609
+ React.createElement("input", {
8610
+ type: "checkbox",
8611
+ "data-lookout-ws-toggle": "",
8612
+ checked: on,
8613
+ onChange: (event) => {
8614
+ const next = event.target.checked === true;
8615
+ setOn(next);
8616
+ setEnabled(next);
8617
+ }
8618
+ }),
8619
+ React.createElement("span", null, label("toggleLabel")))),
8620
+ notice !== "" ? React.createElement("div", { style: WS_STYLE_HINT }, notice) : null,
8621
+ React.createElement("div", { style: WS_STYLE_HINT }, label("toggleHint")),
8622
+ React.createElement("div", { style: WS_STYLE_HINT }, label("pinnedHint")));
8623
+ }
8624
+ const slots = ctx !== null && ctx !== undefined ? ctx.slots : null;
8625
+ if (slots !== null && slots !== undefined && typeof slots.inject === "function" && typeof slots.register === "function") {
8626
+ slots.inject("settings.section", () => slots.register({
8627
+ name: "settings.section",
8628
+ id: "lookout-workspaces",
8629
+ order: 56,
8630
+ label: () => translate("nav"),
8631
+ locale: WS_ROWS_NS,
8632
+ inject: () => ({ t: translate, wsRows: api })
8633
+ }, (props) => React.createElement(WorkspaceRowsSection, Object.assign({}, props, {
8634
+ t: props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate,
8635
+ wsRows: api
8636
+ }))));
8637
+ }
8638
+ }
8639
+ } catch (error) {
8640
+ // Settings are optional; the tags and hiding are not.
8641
+ }
8642
+
8643
+ // ---- styles ---------------------------------------------------------
8644
+ function ensureStyleTag(key, css) {
8645
+ try {
8646
+ const existing = document.querySelector('style[data-plugin-css="' + key + '"]');
8647
+ if (existing !== null && existing !== undefined) {
8648
+ // A linked (hot-reloaded) client keeps its <style> tags alive. Refresh
8649
+ // stale text so a new build's CSS actually reaches the page.
8650
+ if (existing.textContent !== css) {
8651
+ try { existing.textContent = css; } catch { /* tag gone */ }
8652
+ }
8653
+ return existing;
8654
+ }
8655
+ const tag = document.createElement("style");
8656
+ tag.setAttribute("data-plugin", "dsh-lookout");
8657
+ tag.setAttribute("data-plugin-css", key);
8658
+ tag.textContent = css;
8659
+ const head = document.head !== null && document.head !== undefined ? document.head : document.body;
8660
+ if (head !== null && head !== undefined) head.appendChild(tag);
8661
+ return tag;
8662
+ } catch { return null; }
8663
+ }
8664
+
8665
+ function ensureStaticStyle() {
8666
+ ensureStyleTag(WS_STYLE_KEY, WS_ROWS_CSS);
8667
+ }
8668
+
8669
+ function ensureHideStyleTag() {
8670
+ return ensureStyleTag(WS_HIDE_STYLE_KEY, "");
8671
+ }
8672
+
8673
+ // ---- DOM lookups ----------------------------------------------------
8674
+ function container() {
8675
+ try {
8676
+ return document.querySelector(WS_CONTAINER_SELECTOR) || document.querySelector(WS_CONTAINER_FALLBACK) || null;
8677
+ } catch { return null; }
8678
+ }
8679
+
8680
+ function allRowNodes(root) {
8681
+ try {
8682
+ return Array.prototype.slice.call(root.querySelectorAll("[" + WS_ROW_KEY_ATTR + "]"));
8683
+ } catch { return []; }
8684
+ }
8685
+
8686
+ function workspaceRows(root) {
8687
+ return allRowNodes(root).filter((node) => {
8688
+ const key = node.getAttribute(WS_ROW_KEY_ATTR);
8689
+ return typeof key === "string" && key.indexOf(WS_WORKSPACE_PREFIX) === 0;
8690
+ });
8691
+ }
8692
+
8693
+ function rowIdOf(row) {
8694
+ const key = row.getAttribute(WS_ROW_KEY_ATTR);
8695
+ if (typeof key !== "string" || key.indexOf(WS_WORKSPACE_PREFIX) !== 0) return null;
8696
+ const id = key.slice(WS_WORKSPACE_PREFIX.length);
8697
+ return wsRowsValidId(id) ? id : null;
8698
+ }
8699
+
8700
+ function domWorkspaceIds() {
8701
+ const root = container();
8702
+ if (root === null) return [];
8703
+ const ids = [];
8704
+ for (const row of workspaceRows(root)) {
8705
+ const id = rowIdOf(row);
8706
+ if (id !== null) ids.push(id);
8707
+ }
8708
+ return ids;
8709
+ }
8710
+
8711
+ /**
8712
+ * The title element inside one workspace row.
8713
+ *
8714
+ * Primary match: the class token the shipped row gives its title
8715
+ * (YDXeBa_title -> a token ending in "title"). Fallback: the only descendant
8716
+ * span that holds text and no element children. Ambiguity yields null (a
8717
+ * missing badge), never a badge on the wrong row.
8718
+ */
8719
+ function findTitleElement(row) {
8720
+ let spans;
8721
+ try { spans = Array.prototype.slice.call(row.querySelectorAll("span")); } catch { return null; }
8722
+ for (const span of spans) {
8723
+ const tokens = String(span.className === null || span.className === undefined ? "" : span.className).split(/\s+/);
8724
+ for (const token of tokens) {
8725
+ if (token !== "" && /(?:^|[_-])title$/i.test(token)) return span;
8726
+ }
8727
+ }
8728
+ const holders = [];
8729
+ for (const span of spans) {
8730
+ const text = typeof span.textContent === "string" ? span.textContent.trim() : "";
8731
+ if (text === "") continue;
8732
+ let hasElementChild = false;
8733
+ for (const child of span.children) {
8734
+ if (child.tagName === "SPAN" || child.tagName === "DIV" || child.tagName === "BUTTON" || child.tagName === "SVG") {
8735
+ hasElementChild = true;
8736
+ break;
8737
+ }
8738
+ }
8739
+ if (!hasElementChild) holders.push(span);
8740
+ }
8741
+ return holders.length === 1 ? holders[0] : null;
8742
+ }
8743
+
8744
+ /**
8745
+ * The direct child span that holds the row's hover actions: the "⋯" menu
8746
+ * and the new-session button. The shipped row gives it a class token ending
8747
+ * in "rowActions"; a row without one puts the tag at the row's end instead.
8748
+ */
8749
+ function findRowActions(row) {
8750
+ let children;
8751
+ try { children = Array.prototype.slice.call(row.children); } catch { return null; }
8752
+ for (const child of children) {
8753
+ if (child.tagName !== "SPAN") continue;
8754
+ const tokens = String(child.className === null || child.className === undefined ? "" : child.className).split(/\s+/);
8755
+ for (const token of tokens) {
8756
+ if (token !== "" && /(?:^|[_-])rowActions$/i.test(token)) return child;
8757
+ }
8758
+ }
8759
+ return null;
8760
+ }
8761
+
8762
+ /**
8763
+ * Where the badge goes: the workspace row itself (div.projectRow), as a
8764
+ * direct child immediately before span.rowActions (or at the row's end when
8765
+ * the host renders no actions). The title only has to exist: the badge no
8766
+ * longer lives inside its flex column, so nothing has to be flipped to a row.
8767
+ */
8768
+ function findTagAnchor(row) {
8769
+ return findTitleElement(row) === null ? null : row;
8770
+ }
8771
+
8772
+ /** True when the badge already sits at its home: row end, left of rowActions. */
8773
+ function badgeInPlace(row, badge, actions) {
8774
+ let children;
8775
+ try { children = Array.prototype.slice.call(row.children); } catch { return false; }
8776
+ const badgeIndex = children.indexOf(badge);
8777
+ if (badgeIndex < 0) return false;
8778
+ if (actions === null || actions.parentNode !== row) return badgeIndex === children.length - 1;
8779
+ return badgeIndex === children.indexOf(actions) - 1;
8780
+ }
8781
+
8782
+ /** Insert or move the badge to its home: row end, but left of rowActions. */
8783
+ function placeBadge(row, badge, actions) {
8784
+ if (actions !== null && actions.parentNode === row) row.insertBefore(badge, actions);
8785
+ else row.appendChild(badge);
8786
+ }
8787
+
8788
+ function badgeIn(row, id) {
8789
+ let nodes;
8790
+ try { nodes = Array.prototype.slice.call(row.querySelectorAll("[" + WS_TAG_ATTR + "]")); } catch { return null; }
8791
+ for (const node of nodes) if (node.getAttribute(WS_TAG_ATTR) === id) return node;
8792
+ return null;
8793
+ }
8794
+
8795
+ function createBadge(id, tag, color) {
8796
+ const badge = document.createElement("span");
8797
+ badge.className = "dsh-lookout-ws-tag";
8798
+ badge.setAttribute(WS_TAG_ATTR, id);
8799
+ badge.setAttribute("data-color", color);
8800
+ badge.title = tag;
8801
+ const dot = document.createElement("span");
8802
+ dot.className = "dsh-lookout-ws-tag-dot";
8803
+ dot.setAttribute("aria-hidden", "true");
8804
+ const text = document.createElement("span");
8805
+ text.className = "dsh-lookout-ws-tag-text";
8806
+ text.setAttribute(WS_TAG_TEXT_ATTR, "");
8807
+ text.textContent = tag;
8808
+ badge.appendChild(dot);
8809
+ badge.appendChild(text);
8810
+ return badge;
8811
+ }
8812
+
8813
+ function updateBadge(badge, tag, color) {
8814
+ badge.setAttribute("data-color", color);
8815
+ badge.title = tag;
8816
+ for (const child of badge.children) {
8817
+ if (child.getAttribute(WS_TAG_TEXT_ATTR) !== null && child.textContent !== tag) { child.textContent = tag; break; }
8818
+ }
8819
+ }
8820
+
8821
+ /**
8822
+ * Reconcile every rendered workspace row against the preferences.
8823
+ *
8824
+ * The badge is row-level: it must be a direct child of the row, immediately
8825
+ * before rowActions, pushed right by margin-left:auto. A badge left in the
8826
+ * old title-anchored spot (inside span.projectText), elsewhere under the
8827
+ * sidebar, or on the wrong side of the row is moved, never duplicated.
8828
+ */
8829
+ function syncBadges(root, rows) {
8830
+ let placed = 0;
8831
+ let missed = 0;
8832
+ let wanted = 0;
8833
+ const known = new Set();
8834
+ for (const row of rows) {
8835
+ const id = rowIdOf(row);
8836
+ if (id === null) continue;
8837
+ known.add(id);
8838
+ const entry = prefs.workspaces[id];
8839
+ const tag = entry !== undefined ? entry.tag : "";
8840
+ if (tag === "") continue;
8841
+ wanted++;
8842
+ const anchor = findTagAnchor(row);
8843
+ if (anchor === null) { missed++; continue; }
8844
+ const actions = findRowActions(anchor);
8845
+ let badge = badgeIn(anchor, id);
8846
+ if (badge === null) {
8847
+ // Migrate a badge an older version left outside this row.
8848
+ try { badge = root.querySelector("[" + WS_TAG_ATTR + '="' + id + '"]'); } catch { badge = null; }
8849
+ }
8850
+ if (badge === null) placeBadge(anchor, createBadge(id, tag, entry.color), actions);
8851
+ else {
8852
+ if (!badgeInPlace(anchor, badge, actions)) placeBadge(anchor, badge, actions);
8853
+ updateBadge(badge, tag, entry.color);
8854
+ }
8855
+ placed++;
8856
+ }
8857
+ let stale;
8858
+ try { stale = Array.prototype.slice.call(root.querySelectorAll("[" + WS_TAG_ATTR + "]")); } catch { stale = []; }
8859
+ for (const badge of stale) {
8860
+ const id = badge.getAttribute(WS_TAG_ATTR);
8861
+ const entry = id !== null ? prefs.workspaces[id] : undefined;
8862
+ if (entry === undefined || entry.tag === "" || !known.has(id)) {
8863
+ try { badge.remove(); } catch { /* gone */ }
8864
+ }
8865
+ }
8866
+ return { placed, missed, wanted };
8867
+ }
8868
+
8869
+ // ---- current-session protection ------------------------------------
8870
+ /** The workspaces the official uiWorkspace service says hold the open session. */
8871
+ function officialCurrentWorkspaceIds() {
8872
+ const ids = new Set();
8873
+ try {
8874
+ const service = ctx !== null && ctx !== undefined && typeof ctx.get === "function" ? ctx.get("uiWorkspace") : null;
8875
+ if (service === null || service === undefined) return ids;
8876
+ const selection = service.selection !== null && service.selection !== undefined && typeof service.selection.getSnapshot === "function"
8877
+ ? service.selection.getSnapshot()
8878
+ : null;
8879
+ const sessionId = selection !== null && typeof selection === "object" ? selection.sessionId : undefined;
8880
+ if (typeof sessionId !== "string" || sessionId === "") return ids;
8881
+ const list = service.workspaces !== null && service.workspaces !== undefined && service.workspaces.list !== null && service.workspaces.list !== undefined
8882
+ ? service.workspaces.list.getSnapshot()
8883
+ : null;
8884
+ const items = list !== null && typeof list === "object" && Array.isArray(list.items) ? list.items : [];
8885
+ let current = null;
8886
+ for (const item of items) {
8887
+ if (item === null || typeof item !== "object") continue;
8888
+ if (!Array.isArray(item.sessionIds) || item.sessionIds.indexOf(sessionId) < 0) continue;
8889
+ if (typeof item.workspaceId === "string" && item.workspaceId !== "") { current = item; break; }
8890
+ }
8891
+ if (current === null) return ids;
8892
+ ids.add(current.workspaceId);
8893
+ const normalizePath = (value) => String(value).replace(/\\/g, "/").replace(/\/+$/, "");
8894
+ const currentPath = typeof current.path === "string" ? normalizePath(current.path) : "";
8895
+ if (currentPath !== "") {
8896
+ for (const item of items) {
8897
+ if (item === null || typeof item !== "object") continue;
8898
+ if (typeof item.workspaceId !== "string" || item.workspaceId === current.workspaceId) continue;
8899
+ const candidate = typeof item.path === "string" ? normalizePath(item.path) : "";
8900
+ if (candidate !== "" && currentPath.indexOf(candidate + "/") === 0) ids.add(item.workspaceId);
8901
+ }
8902
+ }
8903
+ } catch { /* no service: the DOM signals below still protect */ }
8904
+ return ids;
8905
+ }
8906
+
8907
+ function workspaceIdOfElement(element) {
8908
+ const key = element.getAttribute(WS_ROW_KEY_ATTR);
8909
+ if (typeof key !== "string" || key.indexOf(WS_WORKSPACE_PREFIX) !== 0) return null;
8910
+ const id = key.slice(WS_WORKSPACE_PREFIX.length);
8911
+ if (id === "") return "";
8912
+ return wsRowsValidId(id) ? id : null;
8913
+ }
8914
+
8915
+ /**
8916
+ * The workspace this group wrapper owns.
8917
+ *
8918
+ * HoverCard renders its anchor inside a wrapper <span> (the shipped row is a
8919
+ * direct child of that span), so the keyed row is either a direct child of
8920
+ * the group wrapper or one level below it. One level only: nested child
8921
+ * workspaces live under div[role="group"], never as a direct grandchild.
8922
+ */
8923
+ function directWorkspaceChildId(element) {
8924
+ let children;
8925
+ try { children = element.children; } catch { return null; }
8926
+ if (children === null || children === undefined) return null;
8927
+ for (const child of children) {
8928
+ const direct = workspaceIdOfElement(child);
8929
+ if (direct !== null) return direct;
8930
+ let inner;
8931
+ try { inner = child.children; } catch { inner = null; }
8932
+ if (inner === null || inner === undefined) continue;
8933
+ for (const nested of inner) {
8934
+ const id = workspaceIdOfElement(nested);
8935
+ if (id !== null) return id;
8936
+ }
8937
+ }
8938
+ return null;
8939
+ }
8940
+
8941
+ /** Every workspace group that contains this node, nearest first. */
8942
+ function ancestorWorkspaceIds(node, root) {
8943
+ const ids = [];
8944
+ let element = node.parentElement;
8945
+ while (element !== null && element !== undefined && element !== root) {
8946
+ const id = directWorkspaceChildId(element);
8947
+ if (id !== null) ids.push(id);
8948
+ element = element.parentElement;
8949
+ }
8950
+ return ids;
8951
+ }
8952
+
8953
+ /** The shipped row marks the group holding the open session with a *folderActive class. */
8954
+ function marksCurrentGroup(row) {
8955
+ let nodes;
8956
+ try { nodes = Array.prototype.slice.call(row.querySelectorAll("span,div")); } catch { return false; }
8957
+ for (const node of nodes) {
8958
+ const tokens = String(node.className === null || node.className === undefined ? "" : node.className).split(/\s+/);
8959
+ for (const token of tokens) if (token !== "" && /folderActive$/i.test(token)) return true;
8960
+ }
8961
+ return false;
8962
+ }
8963
+
8964
+ /**
8965
+ * Workspace ids whose group must never be hidden.
8966
+ *
8967
+ * Three independent signals, unioned: the official service, the selected
8968
+ * session row (aria-selected / aria-current), and the folderActive mark that
8969
+ * also covers a COLLAPSED group whose session rows are not rendered. If a
8970
+ * selected session cannot be mapped to any workspace group, hide nothing at
8971
+ * all (blockAll): a row the person is reading must never disappear.
8972
+ */
8973
+ function protectedIds(root) {
8974
+ const ids = new Set();
8975
+ let blockAll = false;
8976
+ for (const id of officialCurrentWorkspaceIds()) ids.add(id);
8977
+ for (const node of allRowNodes(root)) {
8978
+ const key = node.getAttribute(WS_ROW_KEY_ATTR);
8979
+ if (typeof key !== "string" || key.indexOf(WS_SESSION_PREFIX) !== 0) continue;
8980
+ const selected = node.getAttribute("aria-selected") === "true" || node.getAttribute("aria-current") === "true";
8981
+ if (!selected) continue;
8982
+ // "" is the ungrouped bucket's key (data-row-key="workspace:"): it counts as
8983
+ // "inside a group, but not inside any real workspace", so hiding the OTHER
8984
+ // workspaces is still safe. Only a selected session with no group wrapper at
8985
+ // all makes us hide nothing.
8986
+ const groupIds = ancestorWorkspaceIds(node, root);
8987
+ if (groupIds.length === 0) blockAll = true;
8988
+ for (const id of groupIds) if (id !== "") ids.add(id);
8989
+ }
8990
+ for (const row of workspaceRows(root)) {
8991
+ const id = rowIdOf(row);
8992
+ if (id !== null && marksCurrentGroup(row)) ids.add(id);
8993
+ }
8994
+ return { ids, blockAll };
8995
+ }
8996
+
8997
+ // ---- hiding ---------------------------------------------------------
8998
+ function renderHide(root) {
8999
+ const style = ensureHideStyleTag();
9000
+ let hidden = 0;
9001
+ let rules = [];
9002
+ try {
9003
+ const guard = protectedIds(root);
9004
+ for (const id of Object.keys(prefs.workspaces)) {
9005
+ if (prefs.workspaces[id].hidden !== true) continue;
9006
+ hidden++;
9007
+ if (guard.blockAll || guard.ids.has(id)) continue;
9008
+ if (!WS_ID_PATTERN.test(id)) continue;
9009
+ // Three shapes, all scoped to ONE or TWO levels above the keyed row, so
9010
+ // an ancestor group is never matched by accident:
9011
+ // div > row (ungrouped bucket; no hover wrapper)
9012
+ // div > span > row (the shipped HoverCard wrapper)
9013
+ // .*groupSection variants (belt and braces for the two shapes)
9014
+ const key = wsRowsCssString(WS_WORKSPACE_PREFIX + id);
9015
+ const attr = "[" + WS_ROW_KEY_ATTR + '="' + key + '"]';
9016
+ rules.push(
9017
+ "div:has(>" + attr + ")," +
9018
+ "div:has(>span>" + attr + ")," +
9019
+ 'div[class*="groupSection"]:has(>' + attr + ")," +
9020
+ 'div[class*="groupSection"]:has(>span>' + attr + ")" +
9021
+ "{display:none!important;}"
9022
+ );
9023
+ }
9024
+ } catch (error) {
9025
+ rules = [];
9026
+ wsRowsWarn("隐藏工作区样式生成失败,已跳过(不影响侧栏):", error);
9027
+ }
9028
+ if (style !== null && style !== undefined) {
9029
+ const text = rules.length === 0 ? "" : WS_HIDE_CSS_HEADER + "\n" + rules.join("\n");
9030
+ try { if (style.textContent !== text) style.textContent = text; } catch { /* gone */ }
9031
+ }
9032
+ return { rules: rules.length, wanted: hidden };
9033
+ }
9034
+
9035
+ // ---- paint ----------------------------------------------------------
9036
+ function paint() {
9037
+ if (disposed || !enabled || givenUp) return;
9038
+ const root = container();
9039
+ if (root === null) return;
9040
+ let rows;
9041
+ try { rows = workspaceRows(root); } catch (error) { noteFailure(error); return; }
9042
+ // Sidebar in rail mode, an empty list, or a build without workspace rows:
9043
+ // nothing to place is NOT a failure.
9044
+ if (rows.length === 0) return;
9045
+ painting = true;
9046
+ let placed = 0;
9047
+ let missed = 0;
9048
+ let wanted = 0;
9049
+ let failure = null;
9050
+ try {
9051
+ const synced = syncBadges(root, rows);
9052
+ placed += synced.placed;
9053
+ missed += synced.missed;
9054
+ wanted += synced.wanted;
9055
+ const hide = renderHide(root);
9056
+ placed += hide.rules;
9057
+ wanted += hide.wanted;
9058
+ } catch (error) {
9059
+ failure = error;
9060
+ } finally {
9061
+ painting = false;
9062
+ paintedAt = Date.now();
9063
+ }
9064
+ if (placed > 0) failures = 0;
9065
+ else if (failure !== null) noteFailure(failure);
9066
+ else if (wanted > 0 && missed > 0) noteFailure("这一轮没有任何工作区行能贴上标签: " + missed + " 个");
9067
+ }
9068
+
9069
+ function noteFailure(why) {
9070
+ if (givenUp || disposed) return;
9071
+ failures++;
9072
+ if (failures === 1) wsRowsWarn("工作区标签注入失败,已降级(不影响侧栏):", why);
9073
+ if (failures < WS_MAX_FAILURES) return;
9074
+ givenUp = true;
9075
+ stopWatching();
9076
+ if (!wsRowsReadFlag(WS_ROWS_DISMISSED_KEY, false)) showToast(translate("givingUp"), true);
9077
+ }
9078
+
9079
+ // ---- watchers -------------------------------------------------------
9080
+ function ensureObserver() {
9081
+ if (disposed || !enabled || givenUp) return;
9082
+ if (typeof window.MutationObserver !== "function") return;
9083
+ const root = container();
9084
+ if (root === null) return;
9085
+ if (observer !== null && observerRoot === root) return;
9086
+ if (observer !== null) { try { observer.disconnect(); } catch { /* gone */ } observer = null; }
9087
+ observer = new window.MutationObserver(onMutations);
9088
+ observerRoot = root;
9089
+ observer.observe(root, { childList: true, subtree: true });
9090
+ }
9091
+
9092
+ function onMutations() {
9093
+ if (disposed || !enabled || givenUp) return;
9094
+ try {
9095
+ if (painting) return;
9096
+ // Ignore only the settle window right after our own paint. Anything later
9097
+ // must reconcile, or a host re-render that took a badge leaves it gone.
9098
+ if (Date.now() - paintedAt < WS_QUIET_MS) return;
9099
+ if (container() === null) { scheduleRetry(); return; }
9100
+ scheduleScan();
9101
+ } catch (error) { noteFailure(error); }
9102
+ }
9103
+
9104
+ function scheduleScan() {
9105
+ if (disposed || !enabled || givenUp) return;
9106
+ if (scanTimer !== 0) { try { window.clearTimeout(scanTimer); } catch { /* gone */ } }
9107
+ scanTimer = window.setTimeout(() => { scanTimer = 0; paint(); }, WS_SCAN_DEBOUNCE);
9108
+ }
9109
+
9110
+ function scheduleRetry() {
9111
+ if (disposed || !enabled || givenUp) return;
9112
+ if (retryTimer !== 0) return;
9113
+ retryTimer = window.setInterval(attachContainer, 1000);
9114
+ }
9115
+
9116
+ function attachContainer() {
9117
+ if (disposed || !enabled || givenUp) return true;
9118
+ const root = container();
9119
+ if (root === null) return false;
9120
+ ensureObserver();
9121
+ paint();
9122
+ if (observer !== null && retryTimer !== 0) { try { window.clearInterval(retryTimer); } catch { /* gone */ } retryTimer = 0; }
9123
+ return true;
9124
+ }
9125
+
9126
+ function startWatching() {
9127
+ if (disposed || !enabled || givenUp) return;
9128
+ ensureObserver();
9129
+ if (container() !== null) paint();
9130
+ else scheduleRetry();
9131
+ // No MutationObserver in this build: the 1s retry is the only updater left.
9132
+ if (observer === null && retryTimer === 0) scheduleRetry();
9133
+ }
9134
+
9135
+ function stopWatching() {
9136
+ if (observer !== null) { try { observer.disconnect(); } catch { /* gone */ } observer = null; }
9137
+ observerRoot = null;
9138
+ if (retryTimer !== 0) { try { window.clearInterval(retryTimer); } catch { /* gone */ } retryTimer = 0; }
9139
+ if (scanTimer !== 0) { try { window.clearTimeout(scanTimer); } catch { /* gone */ } scanTimer = 0; }
9140
+ painting = false;
9141
+ }
9142
+
9143
+ // ---- preferences ----------------------------------------------------
9144
+ function snapshot() {
9145
+ return wsRowsNormalizeDoc(prefs);
9146
+ }
9147
+
9148
+ function subscribePrefs(listener) {
9149
+ if (typeof listener !== "function") return () => {};
9150
+ prefListeners.add(listener);
9151
+ return () => { prefListeners.delete(listener); };
9152
+ }
9153
+
9154
+ function notifyPrefs() {
9155
+ for (const listener of prefListeners) {
9156
+ try { listener(snapshot()); } catch { /* a listener must not break the paint */ }
9157
+ }
9158
+ }
9159
+
9160
+ function commitPrefs(next) {
9161
+ prefs = wsRowsNormalizeDoc(next);
9162
+ localDirty = true;
9163
+ wsRowsWriteLocalPrefs(prefs);
9164
+ notifyPrefs();
9165
+ paint();
9166
+ scheduleSave();
9167
+ }
9168
+
9169
+ function updateEntry(id, patch) {
9170
+ if (disposed || !wsRowsValidId(id)) return null;
9171
+ if (id === "__proto__" || id === "constructor" || id === "prototype") return null;
9172
+ const base = prefs.workspaces[id] !== undefined ? prefs.workspaces[id] : wsRowsNormalizeEntry(null);
9173
+ const next = {
9174
+ tag: base.tag,
9175
+ color: base.color,
9176
+ pinned: base.pinned,
9177
+ hidden: base.hidden,
9178
+ pinAnchor: base.pinAnchor
9179
+ };
9180
+ const wasPinned = base.pinned === true;
9181
+ let pinOn = false;
9182
+ let restoreAnchor = null;
9183
+ if (patch !== null && typeof patch === "object") {
9184
+ if (Object.hasOwn(patch, "tag")) next.tag = wsRowsNormalizeTag(patch.tag);
9185
+ if (Object.hasOwn(patch, "color")) next.color = wsRowsNormalizeColor(patch.color);
9186
+ if (Object.hasOwn(patch, "pinned")) {
9187
+ next.pinned = patch.pinned === true;
9188
+ // Pin: remember who this workspace sat behind, then replay to the top.
9189
+ // Unpin: restore behind that anchor and clear it (a missing anchor
9190
+ // leaves the row where it is).
9191
+ if (next.pinned === true && !wasPinned) {
9192
+ pinOn = true;
9193
+ next.pinAnchor = capturePinAnchor(id);
9194
+ } else if (next.pinned !== true && wasPinned) {
9195
+ restoreAnchor = base.pinAnchor;
9196
+ next.pinAnchor = null;
9197
+ }
9198
+ }
9199
+ if (Object.hasOwn(patch, "hidden")) next.hidden = patch.hidden === true;
9200
+ if (Object.hasOwn(patch, "pinAnchor")) next.pinAnchor = typeof patch.pinAnchor === "string" ? patch.pinAnchor : null;
9201
+ }
9202
+ const entry = wsRowsNormalizeEntry(next);
9203
+ const workspaces = Object.assign({}, prefs.workspaces);
9204
+ if (wsRowsIsDefaultEntry(entry)) delete workspaces[id];
9205
+ else workspaces[id] = entry;
9206
+ commitPrefs({ version: 1, workspaces });
9207
+ if (pinOn) requestPinReplay(0);
9208
+ else if (restoreAnchor !== null) restorePinAnchor(id, restoreAnchor);
9209
+ return entry;
9210
+ }
9211
+
9212
+ // ---- pinning --------------------------------------------------------
9213
+ /**
9214
+ * Bind the Client Workspace service late and non-blockingly. A host
9215
+ * without `workspaces` (or an older one whose ctx cannot inject) keeps
9216
+ * tags, hiding and the preferences table exactly as they were.
9217
+ */
9218
+ function bindWorkspaceService() {
9219
+ if (disposed) return;
9220
+ const inject = ctx !== null && ctx !== undefined && typeof ctx.inject === "function" ? ctx.inject : null;
9221
+ if (inject === null) return;
9222
+ try {
9223
+ inject(["workspaces"], (scope) => {
9224
+ if (disposed) return;
9225
+ let service = null;
9226
+ try { service = scope !== null && scope !== undefined ? scope.workspaces : null; } catch { service = null; }
9227
+ if (service === null || typeof service !== "object" || typeof service.insertBefore !== "function") return;
9228
+ pinService = service;
9229
+ try {
9230
+ const source = service.list;
9231
+ if (source !== null && source !== undefined && typeof source.subscribe === "function") {
9232
+ pinUnsubscribe = source.subscribe(onWorkspaceListChanged);
9233
+ }
9234
+ } catch (error) { notePinWarn("订阅工作区列表失败:", error); }
9235
+ schedulePinReplay(0);
9236
+ });
9237
+ } catch (error) {
9238
+ notePinWarn("工作区置顶初始化失败(不影响标签和隐藏):", error);
9239
+ }
9240
+ }
9241
+
9242
+ function pinItems() {
9243
+ try {
9244
+ const source = pinService !== null && pinService !== undefined ? pinService.list : null;
9245
+ if (source === null || source === undefined || typeof source.getSnapshot !== "function") return [];
9246
+ const snapshot = source.getSnapshot();
9247
+ return snapshot !== null && typeof snapshot === "object" && Array.isArray(snapshot.items) ? snapshot.items : [];
9248
+ } catch { return []; }
9249
+ }
9250
+
9251
+ function pinWorkspaceIds() {
9252
+ const ids = [];
9253
+ for (const item of pinItems()) {
9254
+ if (item === null || typeof item !== "object") continue;
9255
+ const id = typeof item.workspaceId === "string" && item.workspaceId !== ""
9256
+ ? item.workspaceId
9257
+ : (typeof item.id === "string" ? item.id : "");
9258
+ if (wsRowsValidId(id) && ids.indexOf(id) < 0) ids.push(id);
9259
+ }
9260
+ return ids;
9261
+ }
9262
+
9263
+ /** One warning per page: a missing service is a degradation, never an error. */
9264
+ function notePinWarn(message, error) {
9265
+ if (pinWarned) return;
9266
+ pinWarned = true;
9267
+ try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
9268
+ }
9269
+
9270
+ /** One toast and one warning per page; a broken reorder must never repeat. */
9271
+ function notePinFailure(error) {
9272
+ pinFailures++;
9273
+ if (pinFailures >= WS_PIN_MAX_FAILURES) pinSuspended = true;
9274
+ else pinCooldownUntil = Date.now() + WS_PIN_RETRY_MS;
9275
+ if (pinFailedOnce) return;
9276
+ pinFailedOnce = true;
9277
+ notePinWarn("工作区置顶失败(只提示一次):", error);
9278
+ try { showToast(translate("pinFailed"), false); } catch { /* best effort */ }
9279
+ }
9280
+
9281
+ function onWorkspaceListChanged() {
9282
+ if (disposed) return;
9283
+ // Our own moves change the list too: fold them into the running replay
9284
+ // instead of starting another one.
9285
+ if (pinRunning) { pinDirty = true; return; }
9286
+ schedulePinReplay(WS_PIN_DEBOUNCE);
9287
+ }
9288
+
9289
+ function schedulePinReplay(delay) {
9290
+ if (disposed || pinService === null || pinSuspended) return;
9291
+ if (pinTimer !== 0) return;
9292
+ let wait = typeof delay === "number" && delay > 0 ? delay : 0;
9293
+ const now = Date.now();
9294
+ if (pinCooldownUntil > now) wait = Math.max(wait, pinCooldownUntil - now);
9295
+ pinTimer = window.setTimeout(() => { pinTimer = 0; void pinNow(); }, wait);
9296
+ }
9297
+
9298
+ /** An explicit user action clears the failure backoff and replays immediately. */
9299
+ function requestPinReplay(delay) {
9300
+ pinSuspended = false;
9301
+ pinFailures = 0;
9302
+ pinCooldownUntil = 0;
9303
+ if (pinTimer !== 0) { try { window.clearTimeout(pinTimer); } catch { /* gone */ } pinTimer = 0; }
9304
+ schedulePinReplay(delay);
9305
+ }
9306
+
9307
+ /**
9308
+ * Replay pinned workspaces to the front. Zero insertBefore calls when the
9309
+ * order already fits; otherwise the smallest set of moves (one per pinned
9310
+ * workspace that is not already in the correct prefix). Re-entry from our
9311
+ * own moves is folded into pinDirty, and the identical-plan guard plus the
9312
+ * round/move caps stop a host that accepts a move without performing it.
9313
+ * @returns a promise settled when this replay finished.
9314
+ */
9315
+ function pinNow() {
9316
+ if (disposed || pinSuspended) return Promise.resolve();
9317
+ const service = pinService;
9318
+ if (service === null || typeof service.insertBefore !== "function") return Promise.resolve();
9319
+ if (pinRunning) { pinDirty = true; return Promise.resolve(); }
9320
+ pinRunning = true;
9321
+ pinDirty = false;
9322
+ return (async () => {
9323
+ let moves = 0;
9324
+ let lastKey = null;
9325
+ let stalled = false;
9326
+ try {
9327
+ for (let round = 0; round < WS_PIN_MAX_ROUNDS; round++) {
9328
+ if (disposed) break;
9329
+ const plan = wsPinPlan(prefs, pinItems());
9330
+ if (plan === null) { pinDirty = false; break; }
9331
+ if (plan.key === lastKey) { stalled = true; break; }
9332
+ lastKey = plan.key;
9333
+ pinDirty = false;
9334
+ let stop = false;
9335
+ for (const step of plan.steps) {
9336
+ if (disposed || moves >= WS_PIN_MAX_MOVES) { stop = true; break; }
9337
+ try {
9338
+ await service.insertBefore(step.id, step.before);
9339
+ moves++;
9340
+ } catch (error) {
9341
+ notePinFailure(error);
9342
+ stop = true;
9343
+ break;
9344
+ }
9345
+ }
9346
+ if (stop) break;
9347
+ }
9348
+ } finally {
9349
+ pinRunning = false;
9350
+ const pending = pinDirty;
9351
+ pinDirty = false;
9352
+ if (pending && !stalled && !disposed) schedulePinReplay(WS_PIN_DEBOUNCE);
9353
+ }
9354
+ })();
9355
+ }
9356
+
9357
+ /** The workspace right before this one at pin time; null when it was first. */
9358
+ function capturePinAnchor(id) {
9359
+ if (pinService === null) { notePinWarn(translate("pinUnavailable")); return null; }
9360
+ const ids = pinWorkspaceIds();
9361
+ const at = ids.indexOf(id);
9362
+ return at > 0 ? ids[at - 1] : null;
9363
+ }
9364
+
9365
+ /**
9366
+ * Unpin restore: move the workspace back behind its recorded anchor. An
9367
+ * anchor that is gone (or null) leaves the workspace where it is, so a
9368
+ * deletion elsewhere never makes the list jump.
9369
+ */
9370
+ function restorePinAnchor(id, anchor) {
9371
+ if (disposed || pinService === null || typeof anchor !== "string" || anchor === "") return;
9372
+ const ids = pinWorkspaceIds();
9373
+ if (ids.indexOf(id) < 0) return;
9374
+ const at = ids.indexOf(anchor);
9375
+ if (at < 0) return; // anchor gone: stay put (the caller already cleared it)
9376
+ const before = at + 1 < ids.length ? ids[at + 1] : undefined;
9377
+ if (before === id) return; // already right behind the anchor: zero calls
9378
+ try {
9379
+ const result = pinService.insertBefore(id, before);
9380
+ if (result !== null && typeof result === "object" && typeof result.then === "function") {
9381
+ result.catch((error) => notePinFailure(error));
9382
+ }
9383
+ } catch (error) { notePinFailure(error); }
9384
+ }
9385
+
9386
+ function cleanupMissing(ids) {
9387
+ if (disposed) return 0;
9388
+ const list = Array.isArray(ids) ? ids : [];
9389
+ const known = new Set();
9390
+ for (const id of list) if (wsRowsValidId(id)) known.add(id);
9391
+ // Without a live list the button must not wipe every preference.
9392
+ if (known.size === 0) return 0;
9393
+ const workspaces = {};
9394
+ let removed = 0;
9395
+ for (const [id, entry] of Object.entries(prefs.workspaces)) {
9396
+ if (known.has(id)) workspaces[id] = entry;
9397
+ else removed++;
9398
+ }
9399
+ if (removed === 0) return 0;
9400
+ commitPrefs({ version: 1, workspaces });
9401
+ return removed;
9402
+ }
9403
+
9404
+ function rpcFace() {
9405
+ try {
9406
+ if (ctx === null || ctx === undefined) return null;
9407
+ let connection = ctx.connection;
9408
+ if (connection === null || connection === undefined) {
9409
+ connection = typeof ctx.get === "function" ? ctx.get("connection") : null;
9410
+ }
9411
+ const face = connection !== null && connection !== undefined ? connection.rpc : null;
9412
+ return face !== null && face !== undefined && typeof face.call === "function" ? face : null;
9413
+ } catch { return null; }
9414
+ }
9415
+
9416
+ /** Startup pull: adopt the Host document, or keep the local mirror when RPC is unavailable. */
9417
+ function loadFromHost() {
9418
+ if (disposed) return;
9419
+ const face = rpcFace();
9420
+ if (face === null) return;
9421
+ try {
9422
+ face.call("/api", "iconCustom/getWorkspacePrefs", { args: {} }).then((response) => {
9423
+ if (disposed) return;
9424
+ if (response === null || typeof response !== "object" || response.ok !== true) return;
9425
+ // A local edit that happened while the pull was in flight wins; push it.
9426
+ if (localDirty) { scheduleSave(); return; }
9427
+ prefs = wsRowsNormalizeDoc(response.value);
9428
+ wsRowsWriteLocalPrefs(prefs);
9429
+ notifyPrefs();
9430
+ paint();
9431
+ }).catch(() => { /* keep the local mirror */ });
9432
+ } catch { /* keep the local mirror */ }
9433
+ }
9434
+
9435
+ function scheduleSave() {
9436
+ if (disposed) return;
9437
+ if (saveTimer !== 0) { try { window.clearTimeout(saveTimer); } catch { /* gone */ } }
9438
+ saveTimer = window.setTimeout(saveNow, WS_SAVE_DEBOUNCE);
9439
+ }
9440
+
9441
+ function saveNow() {
9442
+ saveTimer = 0;
9443
+ if (disposed) return;
9444
+ const face = rpcFace();
9445
+ // No RPC: the localStorage mirror is the documented fallback, not an error.
9446
+ if (face === null) return;
9447
+ const request = snapshot();
9448
+ try {
9449
+ face.call("/api", "iconCustom/setWorkspacePrefs", { args: { request } }).then((response) => {
9450
+ if (disposed) return;
9451
+ if (response === null || typeof response !== "object" || response.ok !== true) { noteSaveFailure(response); return; }
9452
+ localDirty = false;
9453
+ }).catch(noteSaveFailure);
9454
+ } catch (error) { noteSaveFailure(error); }
9455
+ }
9456
+
9457
+ /** Saved once per page: a broken Host must not turn every keystroke into a toast. */
9458
+ function noteSaveFailure(why) {
9459
+ if (saveFailedOnce) return;
9460
+ saveFailedOnce = true;
9461
+ try { console.warn("dsh-lookout: 工作区偏好保存失败(只提示一次):", why); } catch { /* no console */ }
9462
+ showToast(translate("saveFailed"), false);
9463
+ }
9464
+
9465
+ // ---- switch ---------------------------------------------------------
9466
+ function setEnabled(next) {
9467
+ const on = next === true;
9468
+ enabled = on;
9469
+ wsRowsWriteFlag(WS_ROWS_ENABLED_KEY, on);
9470
+ if (disposed) return;
9471
+ if (!on) {
9472
+ stopWatching();
9473
+ hideToast();
9474
+ removeBadges();
9475
+ const style = ensureHideStyleTag();
9476
+ if (style !== null && style !== undefined) { try { style.textContent = ""; } catch { /* gone */ } }
9477
+ return;
9478
+ }
9479
+ givenUp = false;
9480
+ failures = 0;
9481
+ hideToast();
9482
+ startWatching();
9483
+ requestPinReplay(0);
9484
+ }
9485
+
9486
+ function removeBadges() {
9487
+ try { document.querySelectorAll("[" + WS_TAG_ATTR + "]").forEach((node) => { try { node.remove(); } catch { /* gone */ } }); } catch { /* gone */ }
9488
+ }
9489
+
9490
+ // ---- toast ----------------------------------------------------------
9491
+ function showToast(message, dismissable) {
9492
+ if (disposed) return;
9493
+ if (document.body === null || document.body === undefined) return;
9494
+ hideToast();
9495
+ const box = document.createElement("div");
9496
+ box.className = "dsh-lookout-ws-toast";
9497
+ box.setAttribute(WS_TOAST_ATTR, "");
9498
+ box.setAttribute("role", "status");
9499
+ box.setAttribute("aria-live", "polite");
9500
+ const text = document.createElement("span");
9501
+ text.className = "dsh-lookout-ws-toast-text";
9502
+ text.textContent = message;
9503
+ box.appendChild(text);
9504
+ if (dismissable === true) {
9505
+ const dismiss = document.createElement("button");
9506
+ dismiss.type = "button";
9507
+ dismiss.textContent = translate("dismiss");
9508
+ dismiss.addEventListener("click", (event) => {
9509
+ event.preventDefault();
9510
+ event.stopPropagation();
9511
+ wsRowsWriteFlag(WS_ROWS_DISMISSED_KEY, true);
9512
+ hideToast();
9513
+ });
9514
+ box.appendChild(dismiss);
9515
+ }
9516
+ document.body.appendChild(box);
9517
+ toast = box;
9518
+ paintedAt = Date.now();
9519
+ toastTimer = window.setTimeout(hideToast, WS_TOAST_MS);
9520
+ }
9521
+
9522
+ function hideToast() {
9523
+ if (toastTimer !== 0) { try { window.clearTimeout(toastTimer); } catch { /* gone */ } toastTimer = 0; }
9524
+ if (toast !== null) { try { toast.remove(); } catch { /* gone */ } toast = null; }
9525
+ }
9526
+
9527
+ // ---- lifecycle ------------------------------------------------------
9528
+ function dispose() {
9529
+ if (disposed) return;
9530
+ disposed = true;
9531
+ try { stopWatching(); } catch { /* best effort */ }
9532
+ try { hideToast(); } catch { /* best effort */ }
9533
+ try { removeBadges(); } catch { /* best effort */ }
9534
+ if (saveTimer !== 0) { try { window.clearTimeout(saveTimer); } catch { /* gone */ } saveTimer = 0; }
9535
+ if (pinTimer !== 0) { try { window.clearTimeout(pinTimer); } catch { /* gone */ } pinTimer = 0; }
9536
+ if (pinUnsubscribe !== null) { try { pinUnsubscribe(); } catch { /* gone */ } pinUnsubscribe = null; }
9537
+ pinService = null;
9538
+ try {
9539
+ document.querySelectorAll('style[data-plugin-css="' + WS_STYLE_KEY + '"],style[data-plugin-css="' + WS_HIDE_STYLE_KEY + '"]').forEach((node) => {
9540
+ try { node.remove(); } catch { /* gone */ }
9541
+ });
9542
+ } catch { /* best effort */ }
9543
+ prefListeners.clear();
9544
+ }
9545
+
9546
+ ensureStaticStyle();
9547
+ bindWorkspaceService();
9548
+ startWatching();
9549
+ loadFromHost();
9550
+
9551
+ return {
9552
+ dispose,
9553
+ snapshot,
9554
+ subscribe: subscribePrefs,
9555
+ updateEntry,
9556
+ cleanupMissing,
9557
+ paintNow: paint,
9558
+ pinNow,
9559
+ domWorkspaceIds,
9560
+ setEnabled,
9561
+ isEnabled: () => enabled === true
9562
+ };
9563
+ }
9564
+ //#endregion
9565
+
9566
+ //#region session id in the sidebar hover card
9567
+ /**
9568
+ * The session id, printed inside the sidebar's session hover card.
9569
+ *
9570
+ * Why a slot instead of DOM patching: ui-workspace renders
9571
+ * `renderSlot("sidebar.session.row.hover", { sessionId })` between the card's
9572
+ * time line and its status lines, so this is a documented seat. No host markup
9573
+ * is read or written, and a host upgrade that moves the card carries our line
9574
+ * with it.
9575
+ *
9576
+ * The header badge of the byte-locked dsh-session-info part cannot be edited,
9577
+ * so "move" is delivered as CSS: while this line is ON, that badge is hidden,
9578
+ * which puts the id in exactly one place. Turning the switch OFF restores the
9579
+ * header and drops the card line — i.e. the shipped behaviour, untouched.
9580
+ */
9581
+ const HOVER_SLOT = "sidebar.session.row.hover";
9582
+ /** Own locale namespace: locale.register THROWS on a duplicate namespace. */
9583
+ const HOVER_NS = "dsh-lookout-session-id";
9584
+ /** localStorage switch; absent or unreadable means ON. */
9585
+ const HOVER_ENABLED_KEY = "dsh-lookout:session-id:hover";
9586
+ const HOVER_STYLE_KEY = "lookout-session-id-hover";
9587
+ const HOVER_HIDE_STYLE_KEY = "lookout-session-id-hide";
9588
+ const HOVER_LINE_CLASS = "dsh-lookout-session-id-hover";
9589
+ const HOVER_TOAST_CLASS = "dsh-lookout-session-id-hover-toast";
9590
+ const HOVER_TOAST_ATTR = "data-lookout-session-id-toast";
9591
+ const HOVER_TOAST_MS = 2000;
9592
+ /** The bundled part's own badge class, hidden while the card carries the id. */
9593
+ const HOVER_HEADER_SELECTOR = ".dsh-session-info";
9594
+
9595
+ const HOVER_ZH = {
9596
+ nav: "会话 ID",
9597
+ toggleLabel: "在会话悬停卡片里显示会话 ID(顶部不再重复显示)",
9598
+ toggleHint: "侧栏里鼠标停在某个会话上,弹出的卡片里会多一行等宽字:这个会话的完整 ID(session-…),点一下即可复制。开启时顶部那串会话 ID 会隐藏,避免同一个 ID 显示两处;关掉即恢复原样。",
9599
+ copied: "已复制会话 ID",
9600
+ copyFailed: "复制失败,请手动选择会话 ID 复制",
9601
+ copyHint: "点击复制完整会话 ID",
9602
+ ariaLabel: "复制完整会话 ID"
9603
+ };
9604
+ const HOVER_EN = {
9605
+ nav: "Session ID",
9606
+ toggleLabel: "Show the session ID in the session hover card (hidden in the header)",
9607
+ toggleHint: "Hovering a session in the sidebar opens a card; this adds one monospace line with the session's full ID (session-…), and one click copies it. While this is on, the header stops showing the same id so it appears in exactly one place; turning it off restores the shipped behaviour.",
9608
+ copied: "Session ID copied",
9609
+ copyFailed: "Copy failed — select the session ID and copy it manually",
9610
+ copyHint: "Click to copy the full session ID",
9611
+ ariaLabel: "Copy the full session ID"
9612
+ };
9613
+
9614
+ const HOVER_CSS = [
9615
+ // One quiet line under the card's time, in the same voice as its status rows.
9616
+ "." + HOVER_LINE_CLASS + "{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8b8b8b);opacity:.85;word-break:break-all;cursor:copy;user-select:text;}",
9617
+ "." + HOVER_LINE_CLASS + ":hover,." + HOVER_LINE_CLASS + ":focus-visible{opacity:1;text-decoration:underline;outline:none;}",
9618
+ "." + HOVER_TOAST_CLASS + "{position:fixed;left:50%;bottom:24px;z-index:2147482100;max-width:min(92vw,560px);padding:9px 12px;transform:translateX(-50%);border:1px solid var(--dsw-alias-border-l2,#444);border-radius:10px;background:var(--dsw-alias-bg-overlay,#222);color:var(--dsw-alias-label-primary,#fff);font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif;font-size:12px;line-height:1.5;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-drop,rgba(0,0,0,.25));}",
9619
+ "." + HOVER_TOAST_CLASS + "[data-lookout-session-id-failed]{color:var(--dsw-alias-state-error-primary,#ff6b6b);}"
9620
+ ].join("\n");
9621
+
9622
+ /** Chinese fallback for every translate call, used when locale is unavailable. */
9623
+ function hoverFallbackText(key, params) {
9624
+ const template = typeof HOVER_ZH[key] === "string" ? HOVER_ZH[key] : key;
9625
+ if (params === null || params === undefined || typeof params !== "object") return template;
9626
+ let text = template;
9627
+ for (const name of Object.keys(params)) text = text.split("{" + name + "}").join(String(params[name]));
9628
+ return text;
9629
+ }
9630
+
9631
+ /** Whether the card line is wanted; storage that throws counts as ON. */
9632
+ function hoverReadFlag() {
9633
+ try {
9634
+ const raw = window.localStorage.getItem(HOVER_ENABLED_KEY);
9635
+ if (raw === "1") return true;
9636
+ if (raw === "0") return false;
9637
+ return true;
9638
+ } catch { return true; }
9639
+ }
9640
+
9641
+ /** Persist the switch; a storage failure only costs durability. */
9642
+ function hoverWriteFlag(value) {
9643
+ try { window.localStorage.setItem(HOVER_ENABLED_KEY, value ? "1" : "0"); } catch { /* advisory */ }
9644
+ }
9645
+
9646
+ /** One warning per page at most, and only for a real degradation. */
9647
+ let hoverWarned = false;
9648
+ function hoverWarn(message, error) {
9649
+ if (hoverWarned) return;
9650
+ hoverWarned = true;
9651
+ try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
9652
+ }
9653
+
9654
+ /**
9655
+ * Mount the card line, its settings switch and the header-hiding stylesheet.
9656
+ * Swallows everything: a UI seat may never throw into the host apply().
9657
+ * @param ctx - the merged plugin client context.
9658
+ * @param require - the ModuleLoader require (React lives behind it).
9659
+ * @returns {void}
9660
+ */
9661
+ function sessionIdHoverStart(ctx, require) {
9662
+ try {
9663
+ sessionIdHoverInstall(ctx, require);
9664
+ } catch (error) {
9665
+ hoverWarn("会话 ID 悬停卡片初始化失败,已跳过(不影响宿主):", error);
9666
+ }
9667
+ }
9668
+
9669
+ /**
9670
+ * Install the seat and return its teardown.
9671
+ * @param ctx - the merged plugin client context.
9672
+ * @param require - the ModuleLoader require.
9673
+ * @returns {() => void} disposer.
9674
+ */
9675
+ function sessionIdHoverInstall(ctx, require) {
9676
+ let disposed = false;
9677
+ let enabled = hoverReadFlag();
9678
+ let translate = hoverFallbackText;
9679
+ let toast = null;
9680
+ let toastTimer = 0;
9681
+ const toggleListeners = new Set();
9682
+ const React = require("react");
9683
+
9684
+ /** Every card line and settings switch reads this, so one switch moves both. */
9685
+ function notifyToggle() {
9686
+ for (const listener of Array.from(toggleListeners)) { try { listener(enabled); } catch { /* gone */ } }
9687
+ }
9688
+
9689
+ function subscribeToggle(listener) {
9690
+ if (typeof listener !== "function") return () => {};
9691
+ toggleListeners.add(listener);
9692
+ return () => { toggleListeners.delete(listener); };
9693
+ }
9694
+
9695
+ // ---- locale ---------------------------------------------------------
9696
+ try {
9697
+ const locale = ctx !== null && ctx !== undefined ? ctx.locale : null;
9698
+ if (locale !== null && locale !== undefined && typeof locale.register === "function") {
9699
+ const register = () => locale.register(HOVER_NS, { zh: HOVER_ZH, en: HOVER_EN });
9700
+ if (typeof ctx.effect === "function") ctx.effect(register, "dsh-lookout: session id dictionaries");
9701
+ else register();
9702
+ }
9703
+ if (locale !== null && locale !== undefined && typeof locale.bind === "function") {
9704
+ const bound = locale.bind(HOVER_NS);
9705
+ translate = (key, params) => {
9706
+ let text = key;
9707
+ try { text = bound(key, params); } catch { text = key; }
9708
+ return typeof text === "string" && text !== key ? text : hoverFallbackText(key, params);
9709
+ };
9710
+ }
9711
+ } catch (error) { /* the Chinese fallback is already in translate */ }
9712
+
9713
+ // ---- styles ---------------------------------------------------------
9714
+ function ensureStyleTag(key, css) {
9715
+ try {
9716
+ const existing = document.querySelector('style[data-plugin-css="' + key + '"]');
9717
+ if (existing !== null && existing !== undefined) {
9718
+ if (typeof css === "string" && existing.textContent !== css) { try { existing.textContent = css; } catch { /* gone */ } }
9719
+ return existing;
9720
+ }
9721
+ const tag = document.createElement("style");
9722
+ tag.setAttribute("data-plugin", "dsh-lookout");
9723
+ tag.setAttribute("data-plugin-css", key);
9724
+ tag.textContent = typeof css === "string" ? css : "";
9725
+ const head = document.head !== null && document.head !== undefined ? document.head : document.body;
9726
+ if (head !== null && head !== undefined) head.appendChild(tag);
9727
+ return tag;
9728
+ } catch { return null; }
9729
+ }
9730
+
9731
+ /**
9732
+ * Hide the shipped header badge while the card carries the id.
9733
+ *
9734
+ * The badge lives in a byte-locked part, so it cannot be removed from the
9735
+ * bundle; hiding it with one scoped rule is the reversible half of "move".
9736
+ */
9737
+ function syncHeaderBadge() {
9738
+ if (disposed) return;
9739
+ if (enabled) { ensureStyleTag(HOVER_HIDE_STYLE_KEY, HOVER_HEADER_SELECTOR + "{display:none !important;}"); return; }
9740
+ try {
9741
+ document.querySelectorAll('style[data-plugin-css="' + HOVER_HIDE_STYLE_KEY + '"]').forEach((node) => {
9742
+ try { node.remove(); } catch { /* gone */ }
9743
+ });
9744
+ } catch { /* best effort */ }
9745
+ }
9746
+
9747
+ // ---- toast + clipboard ----------------------------------------------
9748
+ function hideToast() {
9749
+ if (toastTimer !== 0) { try { window.clearTimeout(toastTimer); } catch { /* gone */ } toastTimer = 0; }
9750
+ if (toast !== null) { try { toast.remove(); } catch { /* gone */ } toast = null; }
9751
+ }
9752
+
9753
+ function showToast(message, ok) {
9754
+ if (disposed) return;
9755
+ if (document.body === null || document.body === undefined) return;
9756
+ hideToast();
9757
+ const box = document.createElement("div");
9758
+ box.className = HOVER_TOAST_CLASS;
9759
+ box.setAttribute(HOVER_TOAST_ATTR, "");
9760
+ if (ok !== true) box.setAttribute("data-lookout-session-id-failed", "");
9761
+ box.setAttribute("role", "status");
9762
+ box.setAttribute("aria-live", "polite");
9763
+ box.textContent = message;
9764
+ document.body.appendChild(box);
9765
+ toast = box;
9766
+ toastTimer = window.setTimeout(hideToast, HOVER_TOAST_MS);
9767
+ }
9768
+
9769
+ async function writeClipboard(text) {
9770
+ try {
9771
+ const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9772
+ if (primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === "function") {
9773
+ const result = await primitives.writeClipboard(text);
9774
+ if (result !== false) return true;
9775
+ }
9776
+ } catch { /* fall through to the browser API */ }
9777
+ try {
9778
+ if (typeof navigator !== "undefined" && navigator.clipboard !== null && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === "function") {
9779
+ await navigator.clipboard.writeText(text);
9780
+ return true;
9781
+ }
9782
+ } catch { /* fall through to the textarea */ }
9783
+ try {
9784
+ const area = document.createElement("textarea");
9785
+ area.value = text;
9786
+ area.setAttribute("readonly", "");
9787
+ area.style.position = "fixed";
9788
+ area.style.top = "0";
9789
+ area.style.left = "-9999px";
9790
+ area.style.opacity = "0";
9791
+ document.body.appendChild(area);
9792
+ try { area.focus(); area.select(); } catch { /* focus is advisory */ }
9793
+ const copied = typeof document.execCommand === "function" && document.execCommand("copy") === true;
9794
+ try { area.remove(); } catch { /* gone */ }
9795
+ return copied;
9796
+ } catch { return false; }
9797
+ }
9798
+
9799
+ async function copyId(id) {
9800
+ let copied = false;
9801
+ try { copied = await writeClipboard(id); } catch { copied = false; }
9802
+ if (disposed) return;
9803
+ showToast(copied ? translate("copied") : translate("copyFailed"), copied === true);
9804
+ return copied;
9805
+ }
9806
+
9807
+ // ---- the card line ---------------------------------------------------
9808
+ /**
9809
+ * One card line: the full session id, click or Enter/Space to copy it.
9810
+ *
9811
+ * A `div[role=button]` rather than a `button`: the card is a passive panel
9812
+ * and the row it describes owns the click that opens the session, so this
9813
+ * line must stay a leaf that steals only its own events.
9814
+ */
9815
+ function SessionIdHoverLine(props) {
9816
+ const [on, setOn] = React.useState(() => enabled);
9817
+ React.useEffect(() => subscribeToggle(setOn), []);
9818
+ const id = props !== null && props !== undefined && typeof props.sessionId === "string" ? props.sessionId : "";
9819
+ if (on !== true || id === "") return null;
9820
+ const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
9821
+ const activate = (event) => {
9822
+ if (event !== null && event !== undefined) {
9823
+ if (typeof event.preventDefault === "function") event.preventDefault();
9824
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
9825
+ }
9826
+ void copyId(id);
9827
+ };
9828
+ const onKeyDown = (event) => {
9829
+ if (event === null || event === undefined) return;
9830
+ if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
9831
+ activate(event);
9832
+ };
9833
+ return React.createElement("div", {
9834
+ className: HOVER_LINE_CLASS,
9835
+ role: "button",
9836
+ tabIndex: 0,
9837
+ title: label("copyHint"),
9838
+ "aria-label": label("ariaLabel"),
9839
+ "data-lookout-session-id": id,
9840
+ onClick: activate,
9841
+ onKeyDown
9842
+ }, id);
9843
+ }
9844
+
9845
+ // ---- settings --------------------------------------------------------
9846
+ function SessionIdHoverSection(props) {
9847
+ const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
9848
+ const [checked, setChecked] = React.useState(() => hoverReadFlag());
9849
+ React.useEffect(() => subscribeToggle(setChecked), []);
9850
+ const onChange = (event) => {
9851
+ const next = event.target.checked === true;
9852
+ setChecked(next);
9853
+ setEnabled(next);
9854
+ };
9855
+ return React.createElement("div", { "data-lookout-settings": "session-id" },
9856
+ React.createElement("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)", marginBottom: 8 } }, label("nav")),
9857
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
9858
+ React.createElement("input", { type: "checkbox", checked, onChange }),
9859
+ React.createElement("span", null, label("toggleLabel"))
9860
+ ),
9861
+ React.createElement("div", { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary)" } }, label("toggleHint"))
9862
+ );
9863
+ }
9864
+
9865
+ /** Flip the line and the header rule together, then persist. */
9866
+ function setEnabled(next) {
9867
+ enabled = next === true;
9868
+ hoverWriteFlag(enabled);
9869
+ syncHeaderBadge();
9870
+ if (!enabled) hideToast();
9871
+ notifyToggle();
9872
+ }
9873
+
9874
+ // ---- wiring ----------------------------------------------------------
9875
+ ensureStyleTag(HOVER_STYLE_KEY, HOVER_CSS);
9876
+ syncHeaderBadge();
9877
+
9878
+ try {
9879
+ const slots = ctx !== null && ctx !== undefined ? ctx.slots : null;
9880
+ if (slots !== null && slots !== undefined && typeof slots.inject === "function" && typeof slots.register === "function") {
9881
+ // Order 1: above the scheduled-task section (order 10) and the status
9882
+ // lines, directly under the card's time — the id reads as part of the
9883
+ // card's own header, not as one more status.
9884
+ slots.inject(HOVER_SLOT, () => slots.register({
9885
+ name: HOVER_SLOT,
9886
+ id: "lookout-session-id",
9887
+ order: 1,
9888
+ locale: HOVER_NS,
9889
+ inject: () => ({ t: translate })
9890
+ }, SessionIdHoverLine));
9891
+ slots.inject("settings.section", () => slots.register({
9892
+ name: "settings.section",
9893
+ id: "lookout-session-id",
9894
+ order: 57,
9895
+ label: () => translate("nav"),
9896
+ locale: HOVER_NS,
9897
+ inject: () => ({ t: translate })
9898
+ }, SessionIdHoverSection));
9899
+ }
9900
+ } catch (error) {
9901
+ hoverWarn("会话 ID 插槽注册失败,已跳过(不影响宿主):", error);
9902
+ }
9903
+
9904
+ function dispose() {
9905
+ if (disposed) return;
9906
+ disposed = true;
9907
+ hideToast();
9908
+ toggleListeners.clear();
9909
+ try {
9910
+ document.querySelectorAll('style[data-plugin-css="' + HOVER_STYLE_KEY + '"],style[data-plugin-css="' + HOVER_HIDE_STYLE_KEY + '"]').forEach((node) => { try { node.remove(); } catch { /* gone */ } });
9911
+ } catch { /* best effort */ }
9912
+ }
9913
+
9914
+ if (ctx !== null && ctx !== undefined && typeof ctx.effect === "function") {
9915
+ try { ctx.effect(() => dispose, "dsh-lookout: session id in hover card"); } catch { /* best effort */ }
9916
+ }
9917
+ return dispose;
9918
+ }
9919
+ //#endregion
9920
+
9921
+
8175
9922
  window.__ModuleLoader__.load({
8176
9923
  id: "dsh-lookout",
8177
9924
  factory: (require) => {
@@ -8184,6 +9931,12 @@ window.__ModuleLoader__.load({
8184
9931
  // starts first and owns its own try/catch: a fault in any part (or a
8185
9932
  // missing files panel) only costs this feature, never the host.
8186
9933
  rowMenuStart(ctx, require);
9934
+ // Workspace tags + hiding are independent of the three bundled parts for
9935
+ // the same reason, and own their own try/catch inside wsRowsStart.
9936
+ wsRowsStart(ctx, require);
9937
+ // The session id in the sidebar's hover card rides an official slot
9938
+ // (sidebar.session.row.hover), so it owns its own try/catch too.
9939
+ sessionIdHoverStart(ctx, require);
8187
9940
  for (const m of __mods) { if (m && typeof m.apply === "function") m.apply(ctx); }
8188
9941
  },
8189
9942
  };