dsh-config-manager 0.1.78 → 0.1.80
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/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -11,11 +11,12 @@ import { Badge, Card } from '../common/ui.tsx'
|
|
|
11
11
|
import { Select } from '../common/Select.tsx'
|
|
12
12
|
import { InfoHint } from '../common/InfoHint.tsx'
|
|
13
13
|
import {
|
|
14
|
-
autosyncIntervalMs, autosyncSkipDetail, autosyncStatusText, computeAutosyncCountdown,
|
|
14
|
+
autosyncFailureMessage, autosyncIntervalMs, autosyncSkipDetail, autosyncStatusText, computeAutosyncCountdown,
|
|
15
|
+
formatIntervalDuration,
|
|
15
16
|
} from './sync-view.ts'
|
|
16
17
|
// 展示文本渲染前统一脱敏(与 ErrorBanner/ReportView 同规矩)
|
|
17
18
|
import { redact } from '../../security/redaction.ts'
|
|
18
|
-
import type { AutosyncInterval } from './sync-api.ts'
|
|
19
|
+
import type { AutosyncAutoApplyTier, AutosyncConflictPolicy, AutosyncInterval } from './sync-api.ts'
|
|
19
20
|
import type { ChannelSyncState } from './sync-view.ts'
|
|
20
21
|
import { autosyncIntervalKey } from '../../ui/sync-settings-view.ts'
|
|
21
22
|
import type { SyncAutosyncIntervalKey } from '../../ui/sync-settings-view.ts'
|
|
@@ -33,14 +34,20 @@ function intervalLabel(iv: AutosyncInterval, t: TranslateNS<'config-manager-sync
|
|
|
33
34
|
/* ------------------------------------------- 拆分出的 render 段(t42,同文件内子组件) */
|
|
34
35
|
|
|
35
36
|
/** 自动同步设置(当前通道):总开关 + 间隔下拉 + 状态徽章(文案/倒计时在卡内派生)。 */
|
|
36
|
-
export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdateInterval }: {
|
|
37
|
+
export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdateInterval, onUpdateOptions }: {
|
|
37
38
|
t: TranslateNS<'config-manager-sync'>
|
|
38
39
|
uiT: UiT
|
|
39
40
|
settings: Pick<ChannelSyncState, 'autosync' | 'autosyncEnabled' | 'autosyncInterval'>
|
|
40
41
|
busy: boolean
|
|
41
42
|
onToggleAutosync: (enabled: boolean) => void
|
|
42
43
|
onUpdateInterval: (interval: AutosyncInterval) => void
|
|
44
|
+
/** 真·自动同步的三个策略项(realtime / 自动应用等级 / 冲突策略);只传要改的字段 */
|
|
45
|
+
onUpdateOptions: (patch: { realtime?: boolean; autoApplyTier?: AutosyncAutoApplyTier; conflictPolicy?: AutosyncConflictPolicy }) => void
|
|
43
46
|
}) {
|
|
47
|
+
// 三件套读自宿主状态(缺省值集中在这里,与宿主 DEFAULT_AUTOSYNC_* 的口径一致)
|
|
48
|
+
const realtime = settings.autosync?.realtime ?? true
|
|
49
|
+
const autoApplyTier: AutosyncAutoApplyTier = settings.autosync?.autoApplyTier ?? 'medium'
|
|
50
|
+
const conflictPolicy: AutosyncConflictPolicy = settings.autosync?.conflictPolicy ?? 'local'
|
|
44
51
|
const autosyncText = settings.autosync !== null ? autosyncStatusText(settings.autosync, uiT) : t('autosync.statusNever')
|
|
45
52
|
/** 距下次自动同步剩余 ms(null = 从未运行;0 = 已到期) */
|
|
46
53
|
const autosyncCountdownMs = settings.autosync !== null && settings.autosync.elapsedMs >= 0
|
|
@@ -49,9 +56,10 @@ export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdat
|
|
|
49
56
|
// issue #79:本轮为什么没同步 / 失败了什么,必须看得见 —— 此前只写进 info 级日志
|
|
50
57
|
// (默认日志级别 warn),界面恒无提示,用户只能去翻 sync-autosync.json。
|
|
51
58
|
const skipDetail = settings.autosync !== null ? autosyncSkipDetail(settings.autosync, t) : null
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
// issue #105:只有本轮真的失败才渲染错误原文。此前只看「非空」,于是上一轮(甚至十天前)
|
|
60
|
+
// 的旧错误会被当成当前结论继续展示;判据现在收在 autosyncFailureMessage 一处。
|
|
61
|
+
const failureMessage = settings.autosync !== null ? autosyncFailureMessage(settings.autosync) : null
|
|
62
|
+
const lastMessage = failureMessage === null ? null : redact(failureMessage)
|
|
55
63
|
return (
|
|
56
64
|
<Card>
|
|
57
65
|
<span className={css.groupLabel}>{t('autosync.title')} <InfoHint text={t('autosync.description')} label={t('common.infoHint')} /></span>
|
|
@@ -74,6 +82,41 @@ export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdat
|
|
|
74
82
|
options={AUTOSYNC_INTERVAL_OPTIONS.map((iv) => ({ value: iv, label: intervalLabel(iv, t) }))}
|
|
75
83
|
/>
|
|
76
84
|
</label>
|
|
85
|
+
<label className={css.checkboxRow}>
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
checked={realtime}
|
|
89
|
+
disabled={busy}
|
|
90
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => { onUpdateOptions({ realtime: e.target.checked }) }}
|
|
91
|
+
/>
|
|
92
|
+
<span>{t('autosync.realtime')} <InfoHint text={t('autosync.realtimeHint')} label={t('common.infoHint')} /></span>
|
|
93
|
+
</label>
|
|
94
|
+
<label className={css.field}>
|
|
95
|
+
<span className={css.fieldLabel}>{t('autosync.autoApplyTier')} <InfoHint text={t('autosync.autoApplyTierHint')} label={t('common.infoHint')} /></span>
|
|
96
|
+
<Select
|
|
97
|
+
value={autoApplyTier}
|
|
98
|
+
disabled={busy}
|
|
99
|
+
ariaLabel={t('autosync.autoApplyTier')}
|
|
100
|
+
onChange={(next) => { onUpdateOptions({ autoApplyTier: next as AutosyncAutoApplyTier }) }}
|
|
101
|
+
options={[
|
|
102
|
+
{ value: 'low', label: t('autosync.tierLow') },
|
|
103
|
+
{ value: 'medium', label: t('autosync.tierMedium') },
|
|
104
|
+
]}
|
|
105
|
+
/>
|
|
106
|
+
</label>
|
|
107
|
+
<label className={css.field}>
|
|
108
|
+
<span className={css.fieldLabel}>{t('autosync.conflictPolicy')} <InfoHint text={t('autosync.conflictPolicyHint')} label={t('common.infoHint')} /></span>
|
|
109
|
+
<Select
|
|
110
|
+
value={conflictPolicy}
|
|
111
|
+
disabled={busy}
|
|
112
|
+
ariaLabel={t('autosync.conflictPolicy')}
|
|
113
|
+
onChange={(next) => { onUpdateOptions({ conflictPolicy: next as AutosyncConflictPolicy }) }}
|
|
114
|
+
options={[
|
|
115
|
+
{ value: 'local', label: t('autosync.conflictLocal') },
|
|
116
|
+
{ value: 'ask', label: t('autosync.conflictAsk') },
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
</label>
|
|
77
120
|
<div className={css.statRow}>
|
|
78
121
|
<Badge kind={settings.autosync?.lastRunStatus === 'failed' ? 'error' : settings.autosync?.lastRunStatus === 'skipped' ? 'warn' : 'info'}>
|
|
79
122
|
{autosyncText}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程合并弹窗(T-ui-merge 的装配层)—— 弹窗壳 + 状态 + 请求都在这一个文件里:
|
|
3
|
+
* - 预览 / 裁决草稿 / 执行结果 / 三个进行中标志 / 错误 → 组件自持(不持久化);
|
|
4
|
+
* - 视觉与列表渲染 → `MergePreviewView.tsx`(纯展示);
|
|
5
|
+
* - **底部动作走 Modal.Footer(DESIGN UI-23)**:「执行合并」是写操作 + 过 mutation gate,
|
|
6
|
+
* 必须与「关闭」同处底栏,正文里不制造第二条执行通道。
|
|
7
|
+
*
|
|
8
|
+
* 为什么把状态搬到这里:裁决结果只能由弹窗底栏触发执行,而底栏在 Modal.Body 之外 ——
|
|
9
|
+
* 状态若留在视图里,就要靠 ref / render-prop 把按钮递出去,反而更脆。
|
|
10
|
+
*
|
|
11
|
+
* 方案 A(一键同步命中合并路径):预览由父级那一次 /sync/merge-preview 预置(seed),
|
|
12
|
+
* 打开即渲染、**不再二次请求**;「重新预览」/错误重试才显式再拉一次(只读)。
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useState } from 'react'
|
|
15
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
16
|
+
import { Modal } from '../common/Modal.tsx'
|
|
17
|
+
import { Button } from '../common/ui.tsx'
|
|
18
|
+
import { toast } from '../common/toast-store.ts'
|
|
19
|
+
import { redact } from '../../security/redaction.ts'
|
|
20
|
+
import type {
|
|
21
|
+
MergeApplyResponse, MergePreviewPayload, MergePreviewResponse, SyncApi, SyncPushPayload,
|
|
22
|
+
} from './sync-api.ts'
|
|
23
|
+
import type { SyncChannel } from './sync-view.ts'
|
|
24
|
+
import {
|
|
25
|
+
applyBulkResolution, buildMergeResolutions, hasApplicableSections, initialResolutionDraft,
|
|
26
|
+
mergeDecisionCounts, mergeResultView, mergeSectionRows,
|
|
27
|
+
} from './merge-view.ts'
|
|
28
|
+
import type { MergeBulkAction, MergeResolutionAction, MergeResolutionDraft } from './merge-view.ts'
|
|
29
|
+
import { MergePreviewView } from './MergePreviewView.tsx'
|
|
30
|
+
import css from '../config-manager.module.css'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 预览/执行共用的请求体:**通道请求体在前**(transport / 地址 / 凭据引用,与 push/pull 同源),
|
|
34
|
+
* 再叠加预览可选字段(decryptPassword / pathMappings / snapshotId)。
|
|
35
|
+
* 宿主侧契约是 MergePreviewPayload,但实际请求一直带着通道字段(见 sync-entry.ts 的同款写法的原因)——
|
|
36
|
+
* 两者取交集才是真实形状。
|
|
37
|
+
*/
|
|
38
|
+
type MergeRequestBody = SyncPushPayload & MergePreviewPayload
|
|
39
|
+
|
|
40
|
+
/** 一键同步分派命中合并路径时递进来的种子(预览已取到,通道 = 该次请求用的通道)。 */
|
|
41
|
+
export interface MergeSeed {
|
|
42
|
+
channel: SyncChannel
|
|
43
|
+
preview: MergePreviewResponse
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MergeModalProps {
|
|
47
|
+
/** 打开态(种子的有无即开关:null = 关闭)。关闭时保留壳,交给 Radix 播退场动画。 */
|
|
48
|
+
seed: MergeSeed | null
|
|
49
|
+
onClose: () => void
|
|
50
|
+
api: SyncApi
|
|
51
|
+
t: TranslateNS<'config-manager-sync'>
|
|
52
|
+
/** 该通道的请求体(父级用当次表单快照构造;弹窗不读通道状态、不拼 URL) */
|
|
53
|
+
channelBody: (channel: SyncChannel) => SyncPushPayload
|
|
54
|
+
/** 分区 id → 本地化名称 */
|
|
55
|
+
sectionName: (id: string) => string
|
|
56
|
+
/** 该通道的解密密码(可选,仅内存;留空 = 用本机已存凭据) */
|
|
57
|
+
decryptPasswordOf: (channel: SyncChannel) => string
|
|
58
|
+
/** 父级忙碌(其它同步操作进行中)→ 禁止并发写 */
|
|
59
|
+
busy?: boolean
|
|
60
|
+
/** 执行成功后通知父级刷新同步状态 */
|
|
61
|
+
onApplied?: () => void
|
|
62
|
+
/** 回滚完成后通知父级刷新同步状态 */
|
|
63
|
+
onRolledBack?: () => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 远程合并弹窗:概览 → 分区裁决 → 执行(底栏)→ 结果(含一键回滚)。
|
|
68
|
+
*
|
|
69
|
+
* 草稿**不持久化**:裁决是当次操作的一部分,刷新页面等于放弃本次裁决,
|
|
70
|
+
* 不会静默留下半套选择(与旧行为一致)。
|
|
71
|
+
*/
|
|
72
|
+
export function MergeModal({
|
|
73
|
+
seed, onClose, api, t, channelBody, sectionName, decryptPasswordOf, busy = false,
|
|
74
|
+
onApplied, onRolledBack,
|
|
75
|
+
}: MergeModalProps) {
|
|
76
|
+
const [preview, setPreview] = useState<MergePreviewResponse | null>(seed?.preview ?? null)
|
|
77
|
+
const [draft, setDraft] = useState<MergeResolutionDraft>(() => initialResolutionDraft(seed?.preview ?? null))
|
|
78
|
+
const [result, setResult] = useState<MergeApplyResponse | null>(null)
|
|
79
|
+
const [loading, setLoading] = useState(false)
|
|
80
|
+
const [applying, setApplying] = useState(false)
|
|
81
|
+
const [rollingBack, setRollingBack] = useState(false)
|
|
82
|
+
const [error, setError] = useState<string | null>(null)
|
|
83
|
+
|
|
84
|
+
// 新的种子 = 新的远端快照:旧裁决属于上一份快照,必须整体丢弃(不做「尽量保留」)。
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (seed === null) return
|
|
87
|
+
setPreview(seed.preview)
|
|
88
|
+
setDraft(initialResolutionDraft(seed.preview))
|
|
89
|
+
setResult(null)
|
|
90
|
+
setError(null)
|
|
91
|
+
}, [seed])
|
|
92
|
+
|
|
93
|
+
/** 该通道的请求体(解密密码只进请求体,不落任何状态)。 */
|
|
94
|
+
const requestBody = (channel: SyncChannel): MergeRequestBody => {
|
|
95
|
+
const password = decryptPasswordOf(channel)
|
|
96
|
+
return { ...channelBody(channel), ...(password !== '' ? { decryptPassword: password } : {}) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 只读预览:拉远端快照做三方合并(宿主侧零写入),并按结果重建裁决草稿。 */
|
|
100
|
+
const reload = async (): Promise<void> => {
|
|
101
|
+
if (seed === null) return
|
|
102
|
+
setLoading(true)
|
|
103
|
+
setError(null)
|
|
104
|
+
try {
|
|
105
|
+
const next = await api.mergePreview(requestBody(seed.channel))
|
|
106
|
+
setPreview(next)
|
|
107
|
+
setDraft(initialResolutionDraft(next))
|
|
108
|
+
setResult(null)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
111
|
+
} finally {
|
|
112
|
+
setLoading(false)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const setAction = (sectionId: string, action: MergeResolutionAction): void => {
|
|
117
|
+
setDraft((cur) => ({ ...cur, [sectionId]: action }))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const bulk = (action: MergeBulkAction): void => {
|
|
121
|
+
setDraft((cur) => applyBulkResolution(preview, cur, action))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 执行合并(写动作,过宿主 mutation gate);结果如实展示,包括 pendingConflicts。 */
|
|
125
|
+
const apply = async (): Promise<void> => {
|
|
126
|
+
if (seed === null) return
|
|
127
|
+
setApplying(true)
|
|
128
|
+
setError(null)
|
|
129
|
+
try {
|
|
130
|
+
const report = await api.applyMerge({ ...requestBody(seed.channel), resolutions: buildMergeResolutions(draft) })
|
|
131
|
+
setResult(report)
|
|
132
|
+
if (report.ok) toast.ok(t('merge.toast.applied'))
|
|
133
|
+
else toast.error(t('merge.toast.applyFailed'))
|
|
134
|
+
onApplied?.()
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
137
|
+
setError(message)
|
|
138
|
+
toast.error(t('merge.toast.applyFailed') + t('common.labelSeparator') + redact(message))
|
|
139
|
+
} finally {
|
|
140
|
+
setApplying(false)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 一键回滚:复用既有 /sync/rollback 数据流(restoreId 来自本次执行)。 */
|
|
145
|
+
const rollback = async (): Promise<void> => {
|
|
146
|
+
const view = mergeResultView(result)
|
|
147
|
+
if (view === null || view.restoreId === null) return
|
|
148
|
+
setRollingBack(true)
|
|
149
|
+
try {
|
|
150
|
+
const report = await api.rollback({ restoreId: view.restoreId })
|
|
151
|
+
if (report.full) toast.ok(t('merge.result.rolledBackTo', { id: view.restoreId }))
|
|
152
|
+
else toast.error(t('merge.result.rollbackIncomplete'))
|
|
153
|
+
onRolledBack?.()
|
|
154
|
+
} catch (err) {
|
|
155
|
+
toast.error(t('merge.toast.rollbackFailed') + t('common.labelSeparator') + redact(err instanceof Error ? err.message : String(err)))
|
|
156
|
+
} finally {
|
|
157
|
+
setRollingBack(false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const rows = mergeSectionRows(preview)
|
|
162
|
+
const counts = mergeDecisionCounts(preview, draft)
|
|
163
|
+
// issue #106:门控必须把裁决草稿算进来,否则「0 可自动应用 + 全是冲突」时裁决完也点不动
|
|
164
|
+
const applicable = hasApplicableSections(preview, draft)
|
|
165
|
+
const blocked = busy || loading || preview === null || rows.length === 0
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Modal
|
|
169
|
+
open={seed !== null}
|
|
170
|
+
onClose={onClose}
|
|
171
|
+
title={t('merge.title')}
|
|
172
|
+
wide
|
|
173
|
+
busy={busy || applying}
|
|
174
|
+
cardStyle={{ width: 'min(860px, 100%)', maxHeight: '88vh' }}
|
|
175
|
+
>
|
|
176
|
+
<Modal.Header
|
|
177
|
+
title={t('merge.title')}
|
|
178
|
+
closeLabel={t('common.close')}
|
|
179
|
+
onClose={onClose}
|
|
180
|
+
closeDisabled={busy || applying}
|
|
181
|
+
/>
|
|
182
|
+
<Modal.Body scroll style={{ maxHeight: '72vh' }}>
|
|
183
|
+
<MergePreviewView
|
|
184
|
+
api={api}
|
|
185
|
+
t={t}
|
|
186
|
+
preview={preview}
|
|
187
|
+
draft={draft}
|
|
188
|
+
result={result}
|
|
189
|
+
loading={loading}
|
|
190
|
+
rollingBack={rollingBack}
|
|
191
|
+
error={error}
|
|
192
|
+
busy={busy || applying}
|
|
193
|
+
sectionName={sectionName}
|
|
194
|
+
onReload={() => { void reload() }}
|
|
195
|
+
onAction={setAction}
|
|
196
|
+
onBulk={bulk}
|
|
197
|
+
onRollback={() => { void rollback() }}
|
|
198
|
+
showTitle={false}
|
|
199
|
+
/>
|
|
200
|
+
</Modal.Body>
|
|
201
|
+
{/* 底栏:裁决进度(左)+ 关闭 / 执行合并(右)。执行是唯一写入口。 */}
|
|
202
|
+
<Modal.Footer>
|
|
203
|
+
<span className={css.mergeFooterSummary}>
|
|
204
|
+
{t('merge.apply.summary', { decided: String(counts.decided), needs: String(counts.needsDecision) })}
|
|
205
|
+
{counts.applicable > 0 ? ' · ' + t('merge.apply.willApply', { n: String(counts.applicable) }) : ''}
|
|
206
|
+
</span>
|
|
207
|
+
<span className={css.statusSpacer} />
|
|
208
|
+
<Button variant="ghost" disabled={applying} onClick={onClose}>{t('common.close')}</Button>
|
|
209
|
+
<Button
|
|
210
|
+
variant="primary"
|
|
211
|
+
loading={applying}
|
|
212
|
+
disabled={blocked || !applicable}
|
|
213
|
+
onClick={() => { void apply() }}
|
|
214
|
+
>
|
|
215
|
+
{applying ? t('merge.applying') : t('merge.apply')}
|
|
216
|
+
</Button>
|
|
217
|
+
</Modal.Footer>
|
|
218
|
+
</Modal>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程合并裁决视图(T-ui-merge)—— **纯展示组件**:
|
|
3
|
+
* - 列表投影 / 决策标签 / 计数 / 待裁决提示 → `./merge-view.ts` 纯函数(node 单测覆盖);
|
|
4
|
+
* - 状态(预览 / 裁决草稿 / 执行结果 / 进行中标志)与请求由 `MergeModal.tsx` 持有:
|
|
5
|
+
* 弹窗把「执行合并」放进 Modal.Footer(DESIGN UI-23),因此这里不自持任何写状态。
|
|
6
|
+
*
|
|
7
|
+
* 交互契约(Lead 2026-10-18 冻结):**裁决粒度 = 分区**。
|
|
8
|
+
* 分区卡承载动作按钮(采用本地 / 采用远端 / 采用自动合并),冲突明细只做展示;
|
|
9
|
+
* 因此界面常驻写明「冲突按分区整体裁决(本版不做逐条裁决)」—— 走 ⓘ,不占正文。
|
|
10
|
+
*
|
|
11
|
+
* 两个按钮口径(2026-10-21 用户裁决后):
|
|
12
|
+
* - **「采用自动合并」只在真有自动合并结果时才出现**(decision === 'merge');
|
|
13
|
+
* 以前常驻一个永远灰着的按钮,用户分不清「没有结果」还是「坏了」;
|
|
14
|
+
* - **没有「跳过」按钮**:它既不是解决、又与「什么都不点」等价(未点名的分区引擎不会应用),
|
|
15
|
+
* 留着只会让人以为已经处理过。
|
|
16
|
+
*
|
|
17
|
+
* 诚实性三条(都由本组件兑现):
|
|
18
|
+
* ① 待裁决的分区本次不会被应用,且下次同步仍会出现 —— 用一行常驻提示说明;
|
|
19
|
+
* ② 执行结果里的 pendingConflicts 必须显示出来,不得把「还有冲突」说成「已解决」;
|
|
20
|
+
* ③ 所有展示文本(含引擎回传的三列预览)渲染前过 redact(merge-view.ts 已做一次,
|
|
21
|
+
* 这里对原始错误/告警再过一次)。
|
|
22
|
+
*/
|
|
23
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
24
|
+
import { Badge, Banner, Button, SectionTitle } from '../common/ui.tsx'
|
|
25
|
+
import { CheckIcon, RefreshIcon } from '../common/Icon.tsx'
|
|
26
|
+
import { Skeleton } from '../common/Skeleton.tsx'
|
|
27
|
+
import { ErrorBanner } from '../common/ErrorBanner.tsx'
|
|
28
|
+
import { InfoHint } from '../common/InfoHint.tsx'
|
|
29
|
+
import { redact } from '../../security/redaction.ts'
|
|
30
|
+
import type { MergeApplyResponse, MergePreviewResponse, SyncApi } from './sync-api.ts'
|
|
31
|
+
import {
|
|
32
|
+
hasApplicableSections, mergeDecisionCounts, mergeResultView, mergeSectionRows, mergeSummaryItems,
|
|
33
|
+
needsNoAncestorNotice,
|
|
34
|
+
} from './merge-view.ts'
|
|
35
|
+
import type { MergeBulkAction, MergeResolutionAction, MergeResolutionDraft } from './merge-view.ts'
|
|
36
|
+
import css from '../config-manager.module.css'
|
|
37
|
+
|
|
38
|
+
export interface MergePreviewViewProps {
|
|
39
|
+
/** 展示层翻译器(`config-manager-sync` 字典) */
|
|
40
|
+
api: SyncApi
|
|
41
|
+
t: TranslateNS<'config-manager-sync'>
|
|
42
|
+
/** 预览结果(null = 还没取到;父级持有点位) */
|
|
43
|
+
preview: MergePreviewResponse | null
|
|
44
|
+
/** 裁决草稿(分区 id → 动作;缺省 = 本次不动,由父级初始化) */
|
|
45
|
+
draft: MergeResolutionDraft
|
|
46
|
+
/** 执行结果(null = 本次还没执行过) */
|
|
47
|
+
result: MergeApplyResponse | null
|
|
48
|
+
/** 预览请求在途 */
|
|
49
|
+
loading: boolean
|
|
50
|
+
/** 回滚在途 */
|
|
51
|
+
rollingBack: boolean
|
|
52
|
+
/** 预览/执行错误(原文,渲染前 redact) */
|
|
53
|
+
error: string | null
|
|
54
|
+
/** 父级忙碌(其它同步操作进行中)→ 禁用裁决与批量按钮 */
|
|
55
|
+
busy?: boolean
|
|
56
|
+
/** 分区 id → 本地化名称(父级用 sectionLabeler(cmT) 提供,与同步页其它位置同源) */
|
|
57
|
+
sectionName: (id: string) => string
|
|
58
|
+
/** 重新拉取只读预览(父级负责请求,本组件只发信号) */
|
|
59
|
+
onReload: () => void
|
|
60
|
+
/** 分区级裁决 */
|
|
61
|
+
onAction: (sectionId: string, action: MergeResolutionAction) => void
|
|
62
|
+
/** 批量裁决(对全部分区生效) */
|
|
63
|
+
onBulk: (action: MergeBulkAction) => void
|
|
64
|
+
/** 一键回滚(restoreId 取自本次执行结果) */
|
|
65
|
+
onRollback: () => void
|
|
66
|
+
/** 是否渲染自带标题(默认 true;放进弹窗时由 Modal 提供标题,避免同一个标题出现两次) */
|
|
67
|
+
showTitle?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 远程合并预览 + 裁决 + 结果展示。
|
|
72
|
+
*
|
|
73
|
+
* 版式(2026-10 重做):概览指标块 → 工具栏(批量 / 重新预览)→ 分区卡(裁决片 + 冲突卡)
|
|
74
|
+
* → 语义提示 → 结果卡。执行按钮在弹窗 Footer,故列表结束后不再堆一排按钮。
|
|
75
|
+
*/
|
|
76
|
+
export function MergePreviewView({
|
|
77
|
+
api, t, preview, draft, result, loading, rollingBack, error, busy = false,
|
|
78
|
+
sectionName, onReload, onAction, onBulk, onRollback, showTitle = true,
|
|
79
|
+
}: MergePreviewViewProps) {
|
|
80
|
+
const rows = mergeSectionRows(preview)
|
|
81
|
+
const summary = mergeSummaryItems(preview)
|
|
82
|
+
const counts = mergeDecisionCounts(preview, draft)
|
|
83
|
+
const applicable = hasApplicableSections(preview, draft)
|
|
84
|
+
const anyMerged = rows.some((row) => row.canUseMerged)
|
|
85
|
+
const resultView = mergeResultView(result)
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={css.mergeView}>
|
|
89
|
+
{showTitle && (
|
|
90
|
+
<SectionTitle
|
|
91
|
+
title={t('merge.title')}
|
|
92
|
+
info={<InfoHint text={t('merge.subtitle')} label={t('common.infoHint')} />}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* 概览:四个指标块。色只表达语义(可自动应用 / 待裁决 / 冲突 / 无变化),不做装饰。 */}
|
|
97
|
+
<div className={css.mergeOverview}>
|
|
98
|
+
{summary.map((item) => (
|
|
99
|
+
<div key={item.labelKey} className={css.mergeTile} data-tone={item.tone}>
|
|
100
|
+
<span className={css.mergeTileValue}>{String(item.value)}</span>
|
|
101
|
+
<span className={css.mergeTileLabel}>{t(item.labelKey)}</span>
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{preview !== null && needsNoAncestorNotice(preview) && <Banner kind="warn">{t('merge.noAncestor')}</Banner>}
|
|
107
|
+
|
|
108
|
+
{/* 工具栏:批量裁决(一次改全部分区的草稿)+ 重新预览(只读)。
|
|
109
|
+
「全部采用自动合并」「留到下次」只在有意义时出现(见 MERGE_BULK_BUTTONS 的 filter)。 */}
|
|
110
|
+
<div className={css.mergeToolbar}>
|
|
111
|
+
<span className={css.mergeToolbarLabel}>{t('merge.bulk.title')}</span>
|
|
112
|
+
{MERGE_BULK_BUTTONS.filter((btn) => (
|
|
113
|
+
(btn.action !== 'useMerged' || anyMerged) && (btn.action !== 'leavePending' || counts.needsDecision > 0)
|
|
114
|
+
)).map((btn) => (
|
|
115
|
+
<Button
|
|
116
|
+
key={btn.action}
|
|
117
|
+
size="sm"
|
|
118
|
+
disabled={busy || loading || rows.length === 0}
|
|
119
|
+
onClick={() => { onBulk(btn.action) }}
|
|
120
|
+
>
|
|
121
|
+
{t(btn.key)}
|
|
122
|
+
</Button>
|
|
123
|
+
))}
|
|
124
|
+
<span className={css.statusSpacer} />
|
|
125
|
+
<InfoHint text={t('merge.scopeNotice') + ' ' + t('merge.bulk.hint')} label={t('common.infoHint')} />
|
|
126
|
+
<Button size="sm" disabled={busy || loading} onClick={onReload}>
|
|
127
|
+
<span className={css.mergeIconLabel}>
|
|
128
|
+
<RefreshIcon size={12} />
|
|
129
|
+
{t('merge.reload')}
|
|
130
|
+
</span>
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{loading && <Skeleton count={3} label={t('merge.loading')} />}
|
|
135
|
+
{error !== null && (
|
|
136
|
+
<ErrorBanner
|
|
137
|
+
error={t('merge.error.previewFailed') + t('common.labelSeparator') + redact(error)}
|
|
138
|
+
onRetry={onReload}
|
|
139
|
+
retrying={loading}
|
|
140
|
+
t={api.t}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{preview !== null && !loading && rows.length === 0 && (
|
|
145
|
+
<div className={css.hint}>
|
|
146
|
+
{t('merge.empty')}
|
|
147
|
+
<br />
|
|
148
|
+
{t('merge.emptyHint')}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{rows.length > 0 && (
|
|
153
|
+
<>
|
|
154
|
+
<span className={css.groupLabel}>{t('merge.section.title')}</span>
|
|
155
|
+
<div className={css.mergeScroll} data-fit="modal">
|
|
156
|
+
{rows.map((row) => {
|
|
157
|
+
const current = draft[row.id] ?? (row.needsDecision ? 'skip' : null)
|
|
158
|
+
return (
|
|
159
|
+
<div key={row.id} className={css.mergeSection}>
|
|
160
|
+
<div className={css.mergeSectionHead}>
|
|
161
|
+
<span className={css.mergeSectionName}>{sectionName(row.id)}</span>
|
|
162
|
+
<Badge kind={row.tone}>{t(row.decisionKey)}</Badge>
|
|
163
|
+
<span className={css.statusSpacer} />
|
|
164
|
+
{row.conflictCount > 0 && (
|
|
165
|
+
<span className={css.mergeMeta}>{t('merge.conflictCount', { n: String(row.conflictCount) })}</span>
|
|
166
|
+
)}
|
|
167
|
+
{row.autoMerged !== null && row.autoMerged > 0 && (
|
|
168
|
+
<span className={css.mergeMeta}>{t('merge.autoMergedCount', { n: String(row.autoMerged) })}</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className={css.mergeActions} role="group" aria-label={sectionName(row.id)}>
|
|
173
|
+
{MERGE_ACTION_BUTTONS.filter((btn) => btn.action !== 'useMerged' || row.canUseMerged).map((btn) => {
|
|
174
|
+
const active = current === btn.action
|
|
175
|
+
return (
|
|
176
|
+
<Button
|
|
177
|
+
key={btn.action}
|
|
178
|
+
size="sm"
|
|
179
|
+
disabled={busy}
|
|
180
|
+
className={active ? css.mergeActionActive : undefined}
|
|
181
|
+
title={active ? t('merge.action.current') : undefined}
|
|
182
|
+
onClick={() => { onAction(row.id, btn.action) }}
|
|
183
|
+
>
|
|
184
|
+
<span className={css.mergeIconLabel}>
|
|
185
|
+
{active && <CheckIcon size={12} />}
|
|
186
|
+
{t(btn.key)}
|
|
187
|
+
</span>
|
|
188
|
+
</Button>
|
|
189
|
+
)
|
|
190
|
+
})}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{row.conflicts.map((conflict) => (
|
|
194
|
+
<div key={conflict.path} className={css.mergeConflictRow}>
|
|
195
|
+
<div className={css.mergeConflictPath}>
|
|
196
|
+
<span className={css.mergeKind}>{t(conflict.kindKey)}</span>
|
|
197
|
+
{conflict.path}
|
|
198
|
+
</div>
|
|
199
|
+
<div className={css.mergeConflictReason}>{t(conflict.reasonKey)}</div>
|
|
200
|
+
<div className={css.mergeCols}>
|
|
201
|
+
{MERGE_PREVIEW_COLUMNS.map((col) => {
|
|
202
|
+
const value = col.of(conflict)
|
|
203
|
+
return (
|
|
204
|
+
<div key={col.key} className={css.mergeCol}>
|
|
205
|
+
<span className={css.mergeColLabel}>{t(col.key)}</span>
|
|
206
|
+
<pre className={css.mergeColValue}>{value === '' ? t('merge.preview.empty') : value}</pre>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
})}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* 「待裁决的分区本次不会应用、下次还会出现」是决定性的语义(决定用户敢不敢点执行),压成一行常驻提示。 */}
|
|
219
|
+
{counts.undecided > 0 && (
|
|
220
|
+
<div className={css.hint}>{t('merge.apply.pendingHint', { n: String(counts.undecided) })}</div>
|
|
221
|
+
)}
|
|
222
|
+
{!applicable && <div className={css.hint}>{t('merge.apply.nothingPending')}</div>}
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{resultView !== null && (
|
|
227
|
+
<div className={css.mergeResult}>
|
|
228
|
+
<span className={css.groupLabel}>{t('merge.result.title')}</span>
|
|
229
|
+
<div className={css.kvRow}>
|
|
230
|
+
<span className={css.kvKey}>{t('merge.result.applied')}</span>
|
|
231
|
+
<span className={css.kvValue}>{String(resultView.applied.length)}</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div className={css.kvRow}>
|
|
234
|
+
<span className={css.kvKey}>{t('merge.result.skipped')}</span>
|
|
235
|
+
<span className={css.kvValue}>{String(resultView.skipped.length)}</span>
|
|
236
|
+
</div>
|
|
237
|
+
{resultView.warnings.length > 0 && (
|
|
238
|
+
<div className={css.kvRow}>
|
|
239
|
+
<span className={css.kvKey}>{t('merge.result.warnings')}</span>
|
|
240
|
+
<span className={css.kvValue}>
|
|
241
|
+
{resultView.warnings.map((w) => <div key={w} className={css.hint}>{redact(w)}</div>)}
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
{resultView.restoreId !== null && (
|
|
246
|
+
<div className={css.kvRow}>
|
|
247
|
+
<span className={css.kvKey}>{t('merge.result.restoreId')}</span>
|
|
248
|
+
<span className={css.kvValue}><span className={css.mono}>{resultView.restoreId}</span></span>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
{resultView.applied.length === 0 && resultView.skipped.length === 0 && (
|
|
252
|
+
<div className={css.hint}>{t('merge.result.none')}</div>
|
|
253
|
+
)}
|
|
254
|
+
{resultView.failedCount > 0 && <div className={css.hint}>{t('merge.result.failed', { n: String(resultView.failedCount) })}</div>}
|
|
255
|
+
{resultView.ineffectiveCount > 0 && <div className={css.hint}>{t('merge.result.ineffective', { n: String(resultView.ineffectiveCount) })}</div>}
|
|
256
|
+
{resultView.rolledBack && (
|
|
257
|
+
<Banner kind="info">
|
|
258
|
+
{resultView.rollbackFull === false ? t('merge.result.rollbackIncomplete') : t('merge.result.rolledBack')}
|
|
259
|
+
</Banner>
|
|
260
|
+
)}
|
|
261
|
+
{resultView.hasPendingConflicts && (
|
|
262
|
+
<Banner kind="warn">{t('merge.result.pending', { n: String(resultView.pendingConflicts.length) })}</Banner>
|
|
263
|
+
)}
|
|
264
|
+
{resultView.restoreId !== null && !resultView.rolledBack && (
|
|
265
|
+
<div className={css.actionRow}>
|
|
266
|
+
<Button loading={rollingBack} disabled={busy} onClick={onRollback}>{t('merge.result.rollback')}</Button>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 分区级动作按钮(顺序即界面顺序)。
|
|
277
|
+
*
|
|
278
|
+
* 「采用自动合并」**只在真有 merged 载荷的分区上渲染**(row.canUseMerged)—— 常驻禁用态
|
|
279
|
+
* 让用户以为功能坏了,而不是「这个分区没东西可自动合成」。
|
|
280
|
+
*/
|
|
281
|
+
const MERGE_ACTION_BUTTONS: ReadonlyArray<{
|
|
282
|
+
action: MergeResolutionAction
|
|
283
|
+
key: 'merge.action.useLocal' | 'merge.action.useRemote' | 'merge.action.useMerged'
|
|
284
|
+
}> = [
|
|
285
|
+
{ action: 'keepLocal', key: 'merge.action.useLocal' },
|
|
286
|
+
{ action: 'useRemote', key: 'merge.action.useRemote' },
|
|
287
|
+
{ action: 'useMerged', key: 'merge.action.useMerged' },
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
/** 批量动作(对全部分区生效;纯函数保证「留到下次」口径不在这里重算)。 */
|
|
291
|
+
const MERGE_BULK_BUTTONS: ReadonlyArray<{
|
|
292
|
+
action: MergeBulkAction
|
|
293
|
+
key: 'merge.bulk.keepLocal' | 'merge.bulk.useRemote' | 'merge.bulk.useMerged' | 'merge.bulk.leavePending'
|
|
294
|
+
}> = [
|
|
295
|
+
{ action: 'keepLocal', key: 'merge.bulk.keepLocal' },
|
|
296
|
+
{ action: 'useRemote', key: 'merge.bulk.useRemote' },
|
|
297
|
+
{ action: 'useMerged', key: 'merge.bulk.useMerged' },
|
|
298
|
+
{ action: 'leavePending', key: 'merge.bulk.leavePending' },
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
/** 三列对照(本地 / 远端 / 基准)。 */
|
|
302
|
+
const MERGE_PREVIEW_COLUMNS = [
|
|
303
|
+
{ key: 'merge.col.local', of: (c: { localPreview: string }) => c.localPreview },
|
|
304
|
+
{ key: 'merge.col.remote', of: (c: { remotePreview: string }) => c.remotePreview },
|
|
305
|
+
{ key: 'merge.col.ancestor', of: (c: { ancestorPreview: string }) => c.ancestorPreview },
|
|
306
|
+
] as const
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 一键同步的在途活动条(阶段文案 + 不定态进度)。
|
|
3
|
+
*
|
|
4
|
+
* 单独成文件的理由:主文件有行数棘轮(t42),而这 8 行是**纯展示**——没有状态、没有调用,
|
|
5
|
+
* 抽出去既守住棘轮,也让「在途反馈」这件事只有一个实现。
|
|
6
|
+
*
|
|
7
|
+
* 为什么按钮上的 Spinner 不够:一键同步最慢的一步是拉远端快照做只读合并预览,
|
|
8
|
+
* Spinner 只在按钮内部、且按钮在卡片右侧;整卡可见的活动条才能让用户确认「点了、在动」。
|
|
9
|
+
* 不定态进度是**状态**而非装饰:prefers-reduced-motion 下仍保留(DESIGN §5)。
|
|
10
|
+
*/
|
|
11
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
12
|
+
import css from '../config-manager.module.css'
|
|
13
|
+
|
|
14
|
+
export interface SyncActivityBarProps {
|
|
15
|
+
/** 'preview' = 正在拉远端快照做合并预览(最慢);'sync' = 正在导入/同步。 */
|
|
16
|
+
phase: 'preview' | 'sync'
|
|
17
|
+
t: TranslateNS<'config-manager-sync'>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SyncActivityBar({ phase, t }: SyncActivityBarProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className={css.syncActivity} role="status" aria-live="polite">
|
|
23
|
+
<span className={`${css.progressTrack} ${css.syncActivityTrack}`}>
|
|
24
|
+
<span className={`${css.progressBar} ${css.progressIndeterminate}`} />
|
|
25
|
+
</span>
|
|
26
|
+
<span className={css.syncActivityLabel}>
|
|
27
|
+
{phase === 'preview' ? t('syncflow.checkingRemote') : t('syncflow.syncing')}
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -14,6 +14,7 @@ import { useEffect } from 'react'
|
|
|
14
14
|
|
|
15
15
|
import { redact } from '../../security/redaction.ts'
|
|
16
16
|
import { Badge, Banner, Button, Spinner } from '../common/ui.tsx'
|
|
17
|
+
import { Skeleton } from '../common/Skeleton.tsx'
|
|
17
18
|
import { toast } from '../common/toast-store.ts'
|
|
18
19
|
import { ConsultCard } from '../consult/ConsultCard.tsx'
|
|
19
20
|
import type { ConsultReport } from '../../core/migration-consult.ts'
|
|
@@ -318,7 +319,7 @@ export function SyncConfirmView(props: SyncConfirmViewProps): ReactNode {
|
|
|
318
319
|
</div>
|
|
319
320
|
|
|
320
321
|
{/* Phase 7 迁移前咨询卡(只读健康评分 + 建议) */}
|
|
321
|
-
{consultLoading && <
|
|
322
|
+
{consultLoading && <Skeleton shape="line" count={5} label={api.t('consult.loading')} />}
|
|
322
323
|
{consultReport !== null && <ConsultCard report={consultReport} t={api.t} />}
|
|
323
324
|
|
|
324
325
|
{error !== null && <Banner kind="error">{error}</Banner>}
|