dsh-config-manager 0.2.3 → 0.3.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.
Files changed (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1256 -1185
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. package/src/utils/git-fast-forward.ts +8 -3
@@ -440,37 +440,43 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
440
440
  {/* —— 运行状态:当前运行中的 profile + 本插件启动的实例(可打开 / 可停止) —— */}
441
441
  <Card className={css.card}>
442
442
  <div className={css.groupLabel}>{t('profiles.running.title')}</div>
443
- <div className={css.kvRow}>
444
- <span className={css.kvKey}>{t('profiles.current')}</span>
445
- <span className={css.kvValue}>
446
- {state.current === null ? '—' : <span className={css.mono}>{state.current}</span>}
447
- </span>
448
- </div>
449
- {/* 空态只给状态,不解释机制:「启动」按钮就在每行,端口/开浏览器属该按钮的 title */}
443
+ {/* 2026-11-01:原来「当前:web」一行 + 运行行「web · :3277 · 当前实例」把同一个事实
444
+ 说了两遍,而两行的值起点一个在 110px、一个在 12px(kvKey 88px 只在前者生效),
445
+ 左边缘对不齐 —— app-screens §7.4「一个事实只在一处、一个左边缘」。
446
+ 现在只有一种行:kvKey 列放档案名(与空态那格同宽同起点,两种状态边缘一致),
447
+ kvValue 列放端口与来源。 */}
450
448
  {state.running.length === 0 && (
451
- <div className={css.actionRow}>
452
- <Badge kind="info">{t('profiles.running.none')}</Badge>
449
+ <div className={css.kvRow}>
450
+ <span className={css.kvKey}>{t('profiles.current')}</span>
451
+ <span className={css.kvValue}>
452
+ {state.current === null ? '—' : <span className={css.mono}>{state.current}</span>}
453
+ <Badge kind="info">{t('profiles.running.none')}</Badge>
454
+ </span>
453
455
  </div>
454
456
  )}
455
457
  {state.running.map((record) => (
456
- <div key={record.name} className={css.actionRow}>
458
+ <div key={record.name} className={css.kvRow}>
459
+ <span className={css.kvKey + ' ' + css.mono} title={record.name}>{record.name}</span>
457
460
  <span className={css.kvValue}>
458
- <span className={css.mono}>{record.name}</span>
459
- {record.port !== null && <>{' · '}{t('profiles.running.port', { port: record.port })}</>}
460
- {' · '}{t(record.current ? 'profiles.running.originCurrent' : record.owned ? 'profiles.running.originOwned' : 'profiles.running.originExternal')}
461
+ {record.port !== null && <>{t('profiles.running.port', { port: record.port })}{' · '}</>}
462
+ {t(record.current ? 'profiles.running.originCurrent' : record.owned ? 'profiles.running.originOwned' : 'profiles.running.originExternal')}
461
463
  </span>
462
464
  {record.url !== null && <Button size="sm" href={record.url}>{t('profiles.running.open')}</Button>}
463
- {/* 当前实例不能停自己(进程会死在响应途中):给禁用按钮 + 说明,而不是藏起来 */}
464
- <Button
465
- size="sm"
466
- variant="ghost"
467
- disabled={state.stopping !== null || record.current}
468
- title={record.current ? t('profiles.stop.currentHint') : record.owned ? t('profiles.stop.hint') : t('profiles.stop.hintExternal')}
469
- loading={state.stopping === record.name}
470
- onClick={() => { patch({ stopTargetName: record.name, error: null }) }}
471
- >
472
- {t('profiles.stop')}
473
- </Button>
465
+ {/* 当前实例不能停自己(进程会死在响应途中):不渲染按钮,也不给禁用按钮。
466
+ disabled 的按钮在真实数据里只读作「这里有个坏掉的东西」(app-screens §11);
467
+ 这一行本来已经写着「当前实例」,缺的不是提示而是控件。 */}
468
+ {!record.current && (
469
+ <Button
470
+ size="sm"
471
+ variant="ghost"
472
+ disabled={state.stopping !== null}
473
+ title={record.owned ? t('profiles.stop.hint') : t('profiles.stop.hintExternal')}
474
+ loading={state.stopping === record.name}
475
+ onClick={() => { patch({ stopTargetName: record.name, error: null }) }}
476
+ >
477
+ {t('profiles.stop')}
478
+ </Button>
479
+ )}
474
480
  </div>
475
481
  ))}
476
482
  </Card>
@@ -493,12 +499,14 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
493
499
  )}
494
500
  </Banner>
495
501
  )}
496
- {launch !== null && (
497
- <Banner kind={launchState(launch) === 'ready' ? 'ok' : 'warn'}>
502
+ {/* 启动**就绪**只走 toast(2026-11-01 用户要求):doLaunch 成功那一刻已经发过
503
+ 「已启动档案 X(端口 N)」,而这条横幅把同一句再说一遍,还占掉一整块绿色面板
504
+ ——「打开 / 停止」在下面「运行状态」卡里本来就有(record.url 的「打开」按钮),
505
+ 日志路径只在**没就绪**时需要,那时才留在横幅里。 */}
506
+ {launch !== null && launchState(launch) !== 'ready' && (
507
+ <Banner kind="warn">
498
508
  <div>
499
- {launchState(launch) === 'ready'
500
- ? t('profiles.launch.started', { name: launch.name, port: launch.port })
501
- : t('profiles.launch.pending', { name: launch.name, port: launch.port, seconds: Math.round(DSH_PROFILE_LAUNCH_TIMEOUT_MS / 1000) })}
509
+ {t('profiles.launch.pending', { name: launch.name, port: launch.port, seconds: Math.round(DSH_PROFILE_LAUNCH_TIMEOUT_MS / 1000) })}
502
510
  </div>
503
511
  <div className={css.kvRow}>
504
512
  <span className={css.kvKey}>{t('profiles.launch.log')}</span>
@@ -508,14 +516,6 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
508
516
  <div key={warning} className={css.hint}>{t(launchWarningKey(warning))}</div>
509
517
  ))}
510
518
  <div className={css.actionRow}>
511
- {launch.url !== null && (
512
- <>
513
- <Button size="sm" variant="primary" href={launch.url}>{t('profiles.launch.open')}</Button>
514
- <Button size="sm" onClick={() => { copyText(launch.url ?? '', t, 'profiles.launch.copiedUrl') }}>
515
- {t('profiles.launch.copyUrl')}
516
- </Button>
517
- </>
518
- )}
519
519
  <Button size="sm" variant="ghost" onClick={() => { patch({ launchResult: null }) }}>{t('common.close')}</Button>
520
520
  </div>
521
521
  </Banner>
@@ -610,11 +610,18 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
610
610
  <span className={css.profileRowTitle}>
611
611
  <span className={`${css.mono} ${css.profileRowName}`}>{profile.name}</span>
612
612
  <span className={css.badgeRow}>
613
- {profile.isCurrent && <Badge kind="ok">{t('profiles.current')}</Badge>}
614
- {runningRecord !== undefined && (
615
- <Badge kind="info">
616
- {runningRecord.port !== null ? t('profiles.running.badge', { port: runningRecord.port }) : t('profiles.running.badgePlain')}
617
- </Badge>
613
+ {/* 2026-11-01:这两个徽章原来并排写着「Running」与「·3277 running」——
614
+ 同一个词三格内出现两次,还要额外占 60px 宽(正是把行摘要挤到截断的
615
+ 一部分)。isCurrent 已经由 ok 徽章说清,第二个徽章只需要回答「在哪个
616
+ 端口上」,所以文案收成 ':3277'。非当前实例(外部/本插件启动)没有
617
+ 状态徽章,那时保留 'running' 字样。 */}
618
+ {profile.isCurrent
619
+ ? <Badge kind="ok">{t('profiles.current')}</Badge>
620
+ : runningRecord !== undefined && runningRecord.port === null
621
+ ? <Badge kind="info">{t('profiles.running.badgePlain')}</Badge>
622
+ : null}
623
+ {runningRecord !== undefined && runningRecord.port !== null && (
624
+ <Badge kind="info">{t('profiles.running.portBadge', { port: runningRecord.port })}</Badge>
618
625
  )}
619
626
  {/* 残留行必须自报「未完成的副本」——不得伪装成普通「自定义」档案(t50);非残留行走原形态键 */}
620
627
  <Badge kind={incomplete ? 'warn' : 'info'}>{t(profileShapeLabelKey(profile))}</Badge>
@@ -646,9 +653,11 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
646
653
  )
647
654
  : (
648
655
  <>
656
+ {/* 2026-11-01:这里原来还跟着「node_modules present · patch hot reload」
657
+ 两段 —— 它们与详情弹窗里的同名徽章重复(EnvironmentPanel 详情区),
658
+ 而三颗动作按钮已经把这一行压到尾部截断。行摘要只留三个计数,
659
+ 那两段在点开详情时仍然逐字可见。 */}
649
660
  {t('profiles.row.summary', { bundles: facts.bundles, patch: facts.patchEntries, deps: facts.deps })}
650
- {' · '}{facts.hasNodeModules ? t('profiles.nodeModules.yes') : t('profiles.nodeModules.no')}
651
- {' · '}{profile.patchReload === 'startup' ? t('profiles.patchReload.startup') : t('profiles.patchReload.live')}
652
661
  {profile.updatedAtMs !== null && ` · ${t('profiles.updatedAt', { time: formatProfileTime(profile.updatedAtMs) })}`}
653
662
  </>
654
663
  )}
@@ -656,9 +665,10 @@ export function EnvironmentPanel({ api, t, recoveryApi, recoveryT, incidentApi }
656
665
  </button>
657
666
  <span className={css.actionRow} data-inline>
658
667
  {/* cross-F3:残留行不给启动/停止那一格(generic 必然 notLaunchable);删除恒在 */}
659
- {caps.launchOrStop && rowAction === 'current' && (
660
- <Button size="sm" disabled title={t('profiles.stop.currentHint')}>{t('profiles.current')}</Button>
661
- )}
668
+ {/* 2026-11-01:原来 current 这一格放一颗 disabled 的「运行中」按钮 —— 标题旁
669
+ 已经有 ok 徽章写着同一句话,动作区却因此常年挂着一个按不动的方块。
670
+ 「当前档案没有启动/停止动作」这件事的正确表达是**没有动作**,
671
+ 不是一颗永远灰着的按钮。reactor: app-screens §11 disabled ≠ 可用提示。 */}
662
672
  {caps.launchOrStop && rowAction === 'stop' && runningRecord !== undefined && (
663
673
  <Button
664
674
  size="sm"
@@ -224,7 +224,9 @@ export function DiskUsageCard({ api, infoHintLabel, onBackupsChanged }: DiskUsag
224
224
  <span className={css.cellMeta}>
225
225
  {row.unreadable ? t('diskUsage.unreadable') : row.size + ' · ' + row.files}
226
226
  </span>
227
- <span className={css.dim}>{t('diskUsage.policy.protected')}</span>
227
+ {/* 行尾的 protected 标签已删(2026-11-01):本组上方的说明文字就是
228
+ 「no longer read · never auto-deleted · not part of the total above」,
229
+ 逐行再贴一遍只会把这一行挤到溢出(实测最后一段跑出右边界)。 */}
228
230
  </div>
229
231
  ))}
230
232
  </>
@@ -381,7 +381,9 @@ export function ExportView({ api, t }: ExportViewProps) {
381
381
  >
382
382
  {t('export.run')}
383
383
  </Button>
384
- <Button size="sm" loading={invLoading} disabled={running} title={t('picker.title')} onClick={openPicker}>
384
+ {/* 不再 size="sm"(2026-11-01):同一工具栏里 28px 与 24px 混排,右边缘参差、
385
+ 基线也对不齐(app-screens §8「同排控件同高」)。次要性由 ghost 承担,不由高度承担。 */}
386
+ <Button loading={invLoading} disabled={running} title={t('picker.title')} onClick={openPicker}>
385
387
  {invLoading ? null : <PreviewIcon size={13} />} {t('picker.title')}
386
388
  </Button>
387
389
  {/* 工具栏「模式提示」属「机制怎么工作 + 省事提示」(MOVE 类):收进 ⓘ,
@@ -24,6 +24,7 @@
24
24
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
25
25
  import type { ReactNode } from 'react'
26
26
  import type { SnapshotMeta } from '../../core/restore.ts'
27
+ import { midEllipsis } from '../../ui/mid-ellipsis.ts'
27
28
  import type { BackupScheduleStatus } from '../../ui/backup-schedule.ts'
28
29
  import { backupRunOutcome, type BackupSkipReason } from '../../ui/backup-schedule.ts'
29
30
  import type { BackupFileMeta } from '../../sync/backup-files.ts'
@@ -432,7 +433,7 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
432
433
  {emptyState ? (
433
434
  /* 首用空态:只交代顺序(备份 → 导出 → 导入),动作全交给上方工具栏 ——
434
435
  此前这里又放了一遍「立即备份 / 远程同步」,等于同一屏两个入口。 */
435
- <Card className={`${css.activityCard} ${css.fillCard}`}>
436
+ <Card className={css.activityCard}>
436
437
  <span className={css.groupLabel}>{t('overview.empty.title')}</span>
437
438
  <div className={css.emptySteps}>
438
439
  <Stepper
@@ -446,8 +447,11 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
446
447
  </Card>
447
448
  ) : (
448
449
  <>
449
- {/* 3. 最近活动表(**首页最后一个数据块**:吃掉剩余高度,列表自身内滚) */}
450
- <Card className={`${css.activityCard} ${css.fillCard}`}>
450
+ {/* 3. 最近活动表(**首页最后一个数据块**)。2026-11-01:不再用 .fillCard 撑满视口 ——
451
+ 8 行记录撑到 400px 后,卡内下方留出约 150px 纯空区,被读成「表格还没加载完」
452
+ (app-screens §4.2:卡内 >40px 空带读作未完成)。现在卡片高度随内容,
453
+ 行数多时由 .activityRows 的 max-height 内滚;页底留白属于页面层级,读起来是完整的。 */}
454
+ <Card className={css.activityCard}>
451
455
  <div className={css.activityHeader}>
452
456
  <span className={css.activityTitle}>{t('overview.activity.title')}</span>
453
457
  </div>
@@ -458,6 +462,15 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
458
462
  : activity.length === 0
459
463
  ? <div className={css.activityEmpty}>{data.history === null ? t('overview.activity.unavailable') : t('overview.activity.empty')}</div>
460
464
  : (
465
+ <>
466
+ {/* 列头:14 行记录只有「时间 / 类型·摘要 / 结果」三列,没有名字时
467
+ 用户不知道右边那一列的绿点是什么(app-screens §7.2:有表头才读得懂列)。 */}
468
+ <div className={css.activityHead}>
469
+ <span>{t('overview.activity.colTime')}</span>
470
+ <span>{t('overview.activity.colEvent')}</span>
471
+ <span>{t('overview.activity.colDetail')}</span>
472
+ <span>{t('overview.activity.colResult')}</span>
473
+ </div>
461
474
  <div className={css.activityRows}>
462
475
  {activity.map((item, i) => {
463
476
  const atMs = Date.parse(item.at) || 0
@@ -467,10 +480,16 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
467
480
  <span className={css.activityTime} title={atMs > 0 ? new Date(atMs).toLocaleString() : undefined}>
468
481
  {renderRelTime(atMs, t)}
469
482
  </span>
483
+ {/* kind 独立成列(2026-11-01):原先它挤在摘要行里,「Import · 」的
484
+ 长前缀把真正的区分信息(文件名尾部时间戳)挤出可视宽度,
485
+ 中段省略再准也被前缀吃掉。 */}
486
+ <span className={css.activityKind} title={kindText}>{kindText}</span>
470
487
  <span className={css.activitySummary}>
471
- <span className={css.activityKind}>{kindText} ·</span>
488
+ {/* 中段省略而不是尾部(DESIGN.md §9-5):摘要里的区分信息在尾部
489
+ —— 文件名时间戳后缀、项数 —— 尾部截断会让两条记录看起来一样。
490
+ 52 是 HomePanel 历史上的取值,换实现不改观感。title 保留全文。 */}
472
491
  <span className={css.activitySummaryText} title={displaySummary(item.summary, t)}>
473
- {displaySummary(item.summary, t)}
492
+ {midEllipsis(displaySummary(item.summary, t), 34)}
474
493
  </span>
475
494
  <CopyButton text={item.summary} label={t('overview.activity.copy')} t={t} />
476
495
  </span>
@@ -479,6 +498,7 @@ export function HomePanel({ api, syncApi, historyApi, t, syncT, openForeignImpor
479
498
  )
480
499
  })}
481
500
  </div>
501
+ </>
482
502
  )}
483
503
  </Card>
484
504
  </>
@@ -67,7 +67,6 @@ import css from '../config-manager.module.css'
67
67
  export interface ImportWizardViewProps {
68
68
  api: ConfigManagerApi
69
69
  t: TranslateNS<'config-manager'>
70
- uiT?: UiT; onOpenForeignImport?: () => void // 可选:外部来源入口(见 SelectStep.foreignEntry)
71
70
  }
72
71
 
73
72
  /* 兼容性等级 → 字典键的映射统一在 ../common/compat-label.ts(同步确认页共用同一份,
@@ -432,7 +431,7 @@ function ResultStep(props: {
432
431
  )
433
432
  }
434
433
 
435
- function ImportWizardBody({ api, t, uiT, onOpenForeignImport }: ImportWizardViewProps) {
434
+ function ImportWizardBody({ api, t }: ImportWizardViewProps) {
436
435
  const {
437
436
  imp,
438
437
  wizard,
@@ -511,7 +510,6 @@ function ImportWizardBody({ api, t, uiT, onOpenForeignImport }: ImportWizardView
511
510
  onReset={resetWizard}
512
511
  apiT={api.t}
513
512
  t={t}
514
- foreignEntry={uiT !== undefined && onOpenForeignImport !== undefined ? { uiT, onOpen: onOpenForeignImport } : undefined}
515
513
  />
516
514
  )
517
515
  }
@@ -97,16 +97,8 @@ export function SelectStep(props: {
97
97
  onReset: () => void
98
98
  apiT: UiT
99
99
  t: TranslateNS<'config-manager'>
100
- /**
101
- * 「从其它 agent 导入」入口(t17,**可选**):壳层注入 uiT 与导航回调时才渲染。
102
- *
103
- * 为什么放在本步而不是加一级页签:导入是 Task 面板里的多阶段流程,外部来源只是
104
- * 「包从哪来」的另一种选择 —— 与「选择 ZIP 文件」并列,用户心智上是同一件事的两个入口。
105
- * 老调用方不传该 prop → 本区块不渲染,既有行为逐字不变。
106
- */
107
- foreignEntry?: { uiT: UiT; onOpen: () => void }
108
100
  }) {
109
- const { fileInput, selectModel, uploading, error, onCancel, onPickFile, onReset, apiT, t, foreignEntry } = props
101
+ const { fileInput, selectModel, uploading, error, onCancel, onPickFile, onReset, apiT, t } = props
110
102
  return (
111
103
  <div className={`${css.viewBody} ${css.sparseFill}`}>
112
104
  <SectionTitle title={t('import.select.title')} info={<InfoHint text={t('import.select.hint')} label={t('common.infoHint')} />} />
@@ -143,14 +135,6 @@ export function SelectStep(props: {
143
135
  </Button>
144
136
  </div>
145
137
  {error !== null && <ErrorBanner error={error} onRetry={onReset} t={apiT} />}
146
- {foreignEntry !== undefined && (
147
- <>
148
- <div className={css.actionRow} style={{ marginBottom: 0 }}>
149
- <Button onClick={foreignEntry.onOpen}>{t('foreign.source.title')}</Button>
150
- <InfoHint text={t('foreign.source.hint')} label={t('common.infoHint')} />
151
- </div>
152
- </>
153
- )}
154
138
  </div>
155
139
  )
156
140
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from '../../ui/artifact-view.ts'
19
19
  import type { UiT } from '../../ui/i18n.ts'
20
20
  import type { TranslateNS } from '../client-types.ts'
21
+ import { midEllipsis } from '../../ui/mid-ellipsis.ts'
21
22
  import { Badge, Button, Spinner } from '../common/ui.tsx'
22
23
  import { ExpandChevron } from '../common/Icon.tsx'
23
24
  import { MoreMenu } from '../common/MoreMenu.tsx'
@@ -104,7 +105,14 @@ export function ArtifactRowView({
104
105
  <div className={css.artifactTitle}>
105
106
  {/* 来源标签上标题行(定案 B):来源已由此表达,再放一个 kind 图标属重复表达 */}
106
107
  <span className={css.kindTag}>{uiT(ARTIFACT_KIND_LABEL_KEY[row.kind])}</span>
107
- <span className={css.artifactName} title={name}>{name}</span>
108
+ {/* 文件名走等宽栈 + 中段省略(DESIGN.md:93 / §9-5):尾部才是区分信息
109
+ (同一分钟导出的两份备份只差尾部时间戳),尾部截断会让两行看起来一样。 */}
110
+ <span
111
+ className={row.title.kind === 'text' ? `${css.artifactName} ${css.mono}` : css.artifactName}
112
+ title={name}
113
+ >
114
+ {row.title.kind === 'text' ? midEllipsis(name) : name}
115
+ </span>
108
116
  </div>
109
117
  {/* 元数据是**自由序列**而不是表格列:某一段没有(如快照没有体积)就整段不出现,
110
118
  绝不补一个「—」占位 —— 这里没有对齐问题,占位只会变成噪音(§3 第三条) */}
@@ -127,7 +135,10 @@ export function ArtifactRowView({
127
135
  {primary !== undefined && (
128
136
  <Button
129
137
  size="sm"
130
- variant={primary === 'delete' ? 'danger' : 'primary'}
138
+ // 常态 ghost:八行八颗实心按钮会把 accent 变成壁纸,整屏最亮的是 chrome。
139
+ // 悬停/展开时由 CSS(.rowPrimary)升到 primary —— 那时它才真是「这一行要做的事」。
140
+ variant={primary === 'delete' ? 'danger' : 'ghost'}
141
+ className={primary === 'delete' ? undefined : css.rowPrimary}
131
142
  // 进行中图标由 Button 原语按 loading 自动渲染(DESIGN §14.11:不要手写 Spinner)
132
143
  loading={pendingPrimary}
133
144
  disabled={pending !== null && !pendingPrimary}
@@ -283,7 +283,14 @@ export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpen
283
283
 
284
284
  {/* 计数条:**不显示合计体积** —— 四源里只有备份文件有体积,求和会把「未知」混进「合计」(§6.8 ①) */}
285
285
  <div className={css.libraryFooter}>
286
- <span className={css.libraryCount}>{t('library.summary.count', { count: filtered.length })}</span>
286
+ {/* 计数只在筛选/搜索生效时出现(2026-11-01):无筛选时它与上方「全部 16」是同一个数
287
+ 出现在同一屏的两处 —— app-screens §5「一个数字只出现一处」。筛掉多少要看得出
288
+ 「眼前几条 / 一共几条」,所以这时写成分数而不是重复总数。 */}
289
+ {filtered.length !== rows.length && (
290
+ <span className={css.libraryCount}>
291
+ {t('library.summary.filtered', { shown: filtered.length, total: rows.length })}
292
+ </span>
293
+ )}
287
294
  {/* 加密计数只统计**当前筛选后**的行:此前用 rows(全部来源)⇒ 切到「本机快照」等
288
295
  没有加密项的类别时底部仍显示「1 个加密」,与眼前看到的列表不符(真机反馈)。 */}
289
296
  {encryptedCount > 0 && (
@@ -291,7 +298,10 @@ export function LibraryPanel({ api, syncApi, marketApi, t, uiT, onAction, onOpen
291
298
  )}
292
299
  <InfoHint text={t('backupFiles.hint')} label={t('common.infoHint')} />
293
300
  <span className={css.statusSpacer} />
294
- <Button size="sm" onClick={onOpenExport}>{t('library.new.export')}</Button>
301
+ {/* 底栏四个入口,只有「导出」是这一页的产品级动作(把本机配置打成可分享的包);
302
+ 其余三个是「拿别人的东西进来 / 去别处找」。主按钮只留一个,否则整屏没有焦点
303
+ (app-screens §13:强调色应用在数据与至多一个控件上)。 */}
304
+ <Button size="sm" variant="primary" onClick={onOpenExport}>{t('library.new.export')}</Button>
295
305
  <Button size="sm" onClick={() => { onOpenTask('import') }}>{t('library.new.import')}</Button>
296
306
  <Button size="sm" onClick={() => { onOpenTask('market') }}>{t('library.new.market')}</Button>
297
307
  <Button size="sm" onClick={onOpenPublish}>{t('task.title.publish')}</Button>