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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1256 -1185
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +56 -2
- package/lib/sync/git/git-transport.js +344 -73
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +6 -1
- package/src/sync/git/git-transport.test.ts +351 -14
- package/src/sync/git/git-transport.ts +343 -79
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
- package/src/utils/git-fast-forward.ts +8 -3
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* 约定:只接收「渲染所需的数据 + 回调」;React 状态、副作用与网络调用仍由
|
|
5
5
|
* SyncPanel 持有(单一状态源);可测纯逻辑在 src/ui/sync-settings-view.ts。
|
|
6
6
|
*/
|
|
7
|
+
import { useId } from 'react'
|
|
7
8
|
import type { ChangeEvent } from 'react'
|
|
8
9
|
import type { TranslateNS } from '../client-types.ts'
|
|
9
10
|
import type { UiT } from '../../ui/i18n.ts'
|
|
10
|
-
import { Badge
|
|
11
|
+
import { Badge } from '../common/ui.tsx'
|
|
11
12
|
import { Select } from '../common/Select.tsx'
|
|
12
13
|
import { InfoHint } from '../common/InfoHint.tsx'
|
|
13
14
|
import {
|
|
@@ -60,18 +61,37 @@ export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdat
|
|
|
60
61
|
// 的旧错误会被当成当前结论继续展示;判据现在收在 autosyncFailureMessage 一处。
|
|
61
62
|
const failureMessage = settings.autosync !== null ? autosyncFailureMessage(settings.autosync) : null
|
|
62
63
|
const lastMessage = failureMessage === null ? null : redact(failureMessage)
|
|
64
|
+
/** 开关的 id:标题在左、勾选框在右,靠 htmlFor 把两者绑成一行(见 .toggleRow 注释) */
|
|
65
|
+
const enableId = useId()
|
|
66
|
+
const realtimeId = useId()
|
|
63
67
|
return (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
/* 卡内小节,不再套一层 Card(2026-11-01):本组件唯一的调用点在
|
|
69
|
+
SyncPanel 的「通道动作卡」内部(SyncPanel.tsx:1262 的 <Card> 里、快照选择行下面),
|
|
70
|
+
而 Card 自带 1px 边框 + 12px 内边距 + 10px 外边距 —— 于是卡里出现一张浮起来的卡,
|
|
71
|
+
边界多出一圈、上下白吃 22px,把 Auto Sync 从首屏挤到折叠线以下。
|
|
72
|
+
同页卡内分节的既有口径是 0.5px border-l2 顶线(css 里另有 4 处同款),照它走。 */
|
|
73
|
+
<div className={css.syncSubBlock}>
|
|
74
|
+
{/* 总开关并进标题行(2026-11-01 用户要求「左标题右按钮」):原来标题一行、下面
|
|
75
|
+
再单列一行「☑ 启用」,同一件事占两行,而右侧一直空着。开关此刻就叫「自动同步」是否开启。 */}
|
|
76
|
+
<div className={css.subBlockHead}>
|
|
77
|
+
<span className={css.groupLabel}>{t('autosync.title')} <InfoHint text={t('autosync.description')} label={t('common.infoHint')} /></span>
|
|
78
|
+
<span className={css.statusSpacer} />
|
|
67
79
|
<input
|
|
80
|
+
id={enableId}
|
|
81
|
+
className={css.toggleInput}
|
|
68
82
|
type="checkbox"
|
|
69
83
|
checked={settings.autosyncEnabled}
|
|
70
84
|
disabled={busy}
|
|
85
|
+
aria-label={t('autosync.enable')}
|
|
71
86
|
onChange={(e: ChangeEvent<HTMLInputElement>) => { onToggleAutosync(e.target.checked) }}
|
|
72
87
|
/>
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
</div>
|
|
89
|
+
{/* 关闭时不再展示策略项(2026-11-01):Enabled 没勾选,下面 4 个下拉 + 状态徽章 +
|
|
90
|
+
失败原文仍然全铺开,占掉画布最后三屏里的两屏 —— 用户要按的「合并 / 推送」被推到
|
|
91
|
+
首屏之外,而这一整组在关闭状态下没有一个控件能生效。app-screens §11 与 DESIGN.md §9
|
|
92
|
+
同口径:不给未启用的能力配满配置项。开启后这些控件立刻出现,没有隐藏的能力。 */}
|
|
93
|
+
{settings.autosyncEnabled && (
|
|
94
|
+
<>
|
|
75
95
|
<label className={css.field}>
|
|
76
96
|
<span className={css.fieldLabel}>{t('autosync.interval')} <InfoHint text={t('autosync.intervalHint')} label={t('common.infoHint')} /></span>
|
|
77
97
|
<Select
|
|
@@ -82,15 +102,19 @@ export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdat
|
|
|
82
102
|
options={AUTOSYNC_INTERVAL_OPTIONS.map((iv) => ({ value: iv, label: intervalLabel(iv, t) }))}
|
|
83
103
|
/>
|
|
84
104
|
</label>
|
|
85
|
-
<
|
|
105
|
+
<div className={css.toggleRow}>
|
|
106
|
+
<label className={css.toggleLabel} htmlFor={realtimeId}>{t('autosync.realtime')}</label>
|
|
107
|
+
<InfoHint text={t('autosync.realtimeHint')} label={t('common.infoHint')} />
|
|
108
|
+
<span className={css.statusSpacer} />
|
|
86
109
|
<input
|
|
110
|
+
id={realtimeId}
|
|
111
|
+
className={css.toggleInput}
|
|
87
112
|
type="checkbox"
|
|
88
113
|
checked={realtime}
|
|
89
114
|
disabled={busy}
|
|
90
115
|
onChange={(e: ChangeEvent<HTMLInputElement>) => { onUpdateOptions({ realtime: e.target.checked }) }}
|
|
91
116
|
/>
|
|
92
|
-
|
|
93
|
-
</label>
|
|
117
|
+
</div>
|
|
94
118
|
<label className={css.field}>
|
|
95
119
|
<span className={css.fieldLabel}>{t('autosync.autoApplyTier')} <InfoHint text={t('autosync.autoApplyTierHint')} label={t('common.infoHint')} /></span>
|
|
96
120
|
<Select
|
|
@@ -145,6 +169,8 @@ export function AutosyncCard({ t, uiT, settings, busy, onToggleAutosync, onUpdat
|
|
|
145
169
|
)}
|
|
146
170
|
</div>
|
|
147
171
|
)}
|
|
148
|
-
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
149
175
|
)
|
|
150
176
|
}
|
|
@@ -53,7 +53,15 @@ export function SyncChannelEntryCard({ t, channel, statusInfo, remoteReady, repo
|
|
|
53
53
|
: repoUrl
|
|
54
54
|
return (
|
|
55
55
|
<Card>
|
|
56
|
-
<
|
|
56
|
+
<div className={css.cardTitleRow}>
|
|
57
|
+
<span className={css.groupLabel}>{t('channel.title')} <InfoHint text={t('channel.openHint')} label={t('common.infoHint')} /></span>
|
|
58
|
+
{/* 上次同步是单条事实,贴标题行右端(见 .cardTitleRow 注释) */}
|
|
59
|
+
<span className={css.statusSpacer} />
|
|
60
|
+
<span className={css.factInline}>
|
|
61
|
+
{t('syncStatus.lastSync')}{' '}
|
|
62
|
+
{formatSyncStatusTimestamp(statusInfo?.lastSyncAt)}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
57
65
|
<div className={css.statRow}>
|
|
58
66
|
<Badge kind="info">{t(SYNC_CHANNEL_LABEL_KEY[channel])}</Badge>
|
|
59
67
|
<Badge kind={remoteReady ? 'ok' : 'warn'}>
|
|
@@ -83,24 +91,12 @@ export function SyncChannelEntryCard({ t, channel, statusInfo, remoteReady, repo
|
|
|
83
91
|
<Badge kind={workdirKind}>{t(workdirKey)}</Badge>
|
|
84
92
|
)}
|
|
85
93
|
</div>
|
|
86
|
-
{/*
|
|
94
|
+
{/* 远端地址(Workbench:未配置时也要给硬事实)。
|
|
87
95
|
配置状态只由上面那排徽章表达:同一张卡里用「徽章 + 事实行」两处说同一件事,
|
|
88
96
|
用户得比对两遍才知道是不是同一状态。 */}
|
|
89
|
-
|
|
90
|
-
<div className={css.
|
|
91
|
-
<span className={css.factLabel}>{t('syncStatus.lastSync')}</span>
|
|
92
|
-
<span className={css.factValue}>
|
|
93
|
-
{formatSyncStatusTimestamp(statusInfo?.lastSyncAt)}
|
|
94
|
-
</span>
|
|
95
|
-
</div>
|
|
96
|
-
{statusInfo?.sectionCount !== undefined && (
|
|
97
|
+
{remoteReady && (
|
|
98
|
+
<div className={css.factGrid} style={{ marginTop: 8 }}>
|
|
97
99
|
<div className={css.factCell}>
|
|
98
|
-
<span className={css.factLabel}>{t('syncStatus.sections')}</span>
|
|
99
|
-
<span className={`${css.factValue} ${css.mono}`}>{String(statusInfo.sectionCount)}</span>
|
|
100
|
-
</div>
|
|
101
|
-
)}
|
|
102
|
-
{remoteReady && (
|
|
103
|
-
<div className={css.factCell} style={{ gridColumn: '1 / -1' }}>
|
|
104
100
|
<span className={css.factLabel}>{t('channel.currentUrl')}</span>
|
|
105
101
|
<span className={css.factValue}>
|
|
106
102
|
<span className={css.mono}>
|
|
@@ -108,10 +104,13 @@ export function SyncChannelEntryCard({ t, channel, statusInfo, remoteReady, repo
|
|
|
108
104
|
</span>
|
|
109
105
|
</span>
|
|
110
106
|
</div>
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
113
109
|
<div className={css.actionRowTop}>
|
|
114
|
-
|
|
110
|
+
{/* ghost 而不是 primary(2026-11-01):本页唯一的主行动是「把本地改动推上去 /
|
|
111
|
+
把远端拉下来」,而那是下方 pickSyncPrimary 决定的那颗按钮。
|
|
112
|
+
配置入口与它并列同填充时,用户在首屏看到两个「主按钮」,谁也不会先按。 */}
|
|
113
|
+
<Button variant="ghost" onClick={onOpen}>
|
|
115
114
|
{t('channel.open')}
|
|
116
115
|
</Button>
|
|
117
116
|
{/* 断开出口:只有**配置过**的通道才渲染(未配置时这个按钮没有意义,也不该存在)。
|
|
@@ -44,7 +44,7 @@ import type { ReactNode } from 'react'
|
|
|
44
44
|
import type { TranslateNS } from '../client-types.ts'
|
|
45
45
|
import type { SyncPullReport, SyncPushPreview, SyncPushReport } from '../../sync/sync-engine.ts'
|
|
46
46
|
import type { SectionId } from '../../schema/types.ts'
|
|
47
|
-
import { Badge, Banner, Button, Card, SectionTitle } from '../common/ui.tsx'
|
|
47
|
+
import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
48
48
|
import { ErrorBanner } from '../common/ErrorBanner.tsx'
|
|
49
49
|
import { InfoHint } from '../common/InfoHint.tsx'
|
|
50
50
|
import { Select } from '../common/Select.tsx'
|
|
@@ -1238,32 +1238,35 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
|
|
|
1238
1238
|
<Collapse open={ready && isChannelExpanded(channel)} id={channelContentId(channel)} className={css.syncChannelBody}>
|
|
1239
1239
|
{ready && (
|
|
1240
1240
|
<Card>
|
|
1241
|
-
{/*
|
|
1242
|
-
|
|
1241
|
+
{/* 同步内容跟着通道卡走 —— autosync 与 sync-selection 都按通道独立,做成全局一节
|
|
1242
|
+
会让用户以为两个通道共用一份选择(§8 的关键修正)。
|
|
1243
|
+
2026-11-01:原为独立一张卡,卡内只有「9 selected」+ 一个 Choose…,右侧四百多像素
|
|
1244
|
+
全空(app-screens §4.2 卡内死空间,白吃约 115px 把 Auto Sync 挤出画布)。改成本卡
|
|
1245
|
+
内的选择行:它本来就是动作的前置条件(选哪些分区的改动才参与合并/推送)。 */}
|
|
1243
1246
|
{/* ⓘ 一律跟在**相关标题/操作的右边**(同一行):单独成行会让卡片出现「孤零零一个图标」
|
|
1244
1247
|
的断行。标题内联沿用 AboutPanel / EnvironmentPanel 的既有写法(零新增样式)。 */}
|
|
1245
|
-
<
|
|
1246
|
-
|
|
1247
|
-
{
|
|
1248
|
-
|
|
1249
|
-
:
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1248
|
+
<div className={css.sectionPickerRow}>
|
|
1249
|
+
<span className={css.groupLabel}>{t('mode.title')} <InfoHint text={t('mode.hint')} label={t('common.infoHint')} /></span>
|
|
1250
|
+
<span className={css.categoryDesc}>
|
|
1251
|
+
{state.loadError !== null ? t('load.failed')
|
|
1252
|
+
: state.catalog.length === 0 ? t('common.loading')
|
|
1253
|
+
: t('mode.selectedCount', { n: String(cs.syncSections.length), total: String(state.catalog.length) })}
|
|
1254
|
+
</span>
|
|
1255
|
+
{/* 按钮靠右成列(2026-11-01 用户要求:左描述、右按钮)。行内不再挂「已保存」ⓘ ——
|
|
1256
|
+
它在说一件用户无从选择的事(选完自然保存),与弹窗底那颗重复。 */}
|
|
1257
|
+
<Button className={css.pickerBtn} onClick={() => { openSectionPickerFor(channel) }} disabled={busy}>
|
|
1253
1258
|
{t('mode.choose')}
|
|
1254
1259
|
</Button>
|
|
1255
|
-
<InfoHint text={t('mode.persistHint')} label={t('common.infoHint')} />
|
|
1256
1260
|
</div>
|
|
1257
1261
|
{cs.syncSections.length === 0 && <Banner kind="warn">{t('mode.atLeastOne')}</Banner>}
|
|
1258
1262
|
{gistSessionsRiskKey(channel, cs.syncSections) !== null && <Banner kind="warn">{t(gistSessionsRiskKey(channel, cs.syncSections)!)}</Banner>}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
{
|
|
1264
|
-
合并裁决并入下行按钮(有基线走三方裁决视图、无基线走老导入确认);并排两个按钮看不出该按哪个(issue 115)⇒ 按已确证远端事实只让一个落在 primary。 */}
|
|
1265
|
-
<div className={css.actionRow}>
|
|
1263
|
+
{/* 动作行(方案 A):用户只面对两个方向,合并裁决并入下行(有基线走三方裁决、无基线走导入确认),
|
|
1264
|
+
按已确证远端事实只让一个落在 primary(issue 115:并排两个都亮看不出按哪个)。
|
|
1265
|
+
2026-11-01:两枚改为平分整行宽(flex 1 1 0)—— 三个宽度各异的文字按钮并排时右边缘参差、
|
|
1266
|
+
每个都像主行动;只读自检因此退到下方工具组(与「刷新快照」同款 28px 图标按钮,title 给全名)。 */}
|
|
1267
|
+
<div className={css.actionRow} data-split>
|
|
1266
1268
|
<Button
|
|
1269
|
+
className={css.splitBtn}
|
|
1267
1270
|
variant={pickSyncPrimary(state.statusInfo, channel) === 'sync' ? 'primary' : 'ghost'}
|
|
1268
1271
|
disabled={busy}
|
|
1269
1272
|
loading={state.busy === 'sync'}
|
|
@@ -1273,14 +1276,9 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
|
|
|
1273
1276
|
? (syncPhase === 'preview' ? t('syncflow.checkingRemote') : t('syncflow.syncing'))
|
|
1274
1277
|
: t('syncflow.button')}
|
|
1275
1278
|
</Button>
|
|
1276
|
-
<Button variant={pickSyncPrimary(state.statusInfo, channel) === 'push' ? 'primary' : 'ghost'} disabled={!computeSyncButtons(state.busy, ready, uiT).canPush || githubBusy || !hasSelectedSections(cs) || computeEncryptInvalid(cs)} onClick={() => { switchChannel(channel); void runPushPreview() }}>
|
|
1279
|
+
<Button className={css.splitBtn} variant={pickSyncPrimary(state.statusInfo, channel) === 'push' ? 'primary' : 'ghost'} disabled={!computeSyncButtons(state.busy, ready, uiT).canPush || githubBusy || !hasSelectedSections(cs) || computeEncryptInvalid(cs)} onClick={() => { switchChannel(channel); void runPushPreview() }}>
|
|
1277
1280
|
{computeSyncButtons(state.busy, ready, uiT).pushLabel}
|
|
1278
1281
|
</Button>
|
|
1279
|
-
{/* 测试连接(只读自检):与两个主操作同一行、排在最后;结果走 toast(不再占页面高度) */}
|
|
1280
|
-
<SyncProbeRow
|
|
1281
|
-
t={t} api={api} presentation="action" remoteReady={ready} busy={busy}
|
|
1282
|
-
body={buildSyncChannelBody({ ...formOf(state), channel })}
|
|
1283
|
-
/>
|
|
1284
1282
|
</div>
|
|
1285
1283
|
{/* 同步在途的活动条:按钮 Spinner 只在按钮上,这条横条让「正在拉远端快照」在整张卡上可见 */}
|
|
1286
1284
|
{syncPhase !== null && <SyncActivityBar phase={syncPhase} t={t} />}
|
|
@@ -1311,15 +1309,16 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
|
|
|
1311
1309
|
]}
|
|
1312
1310
|
/>
|
|
1313
1311
|
</label>
|
|
1312
|
+
{/* 工具组(2026-11-01):刷新与只读自检都是低频诊断动作、都只服务这一行,收成两枚 28px 图标
|
|
1313
|
+
按钮(与下拉同高、同组右对齐)。此前刷新是带图标的宽文字按钮,把下拉压到约 380px。 */}
|
|
1314
1314
|
<span className={css.pickerAction}>
|
|
1315
|
-
<
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
{
|
|
1322
|
-
</Button>
|
|
1315
|
+
<button type="button" className={css.iconBtn} onClick={() => { void loadSnapshots(undefined, channel, true) }}
|
|
1316
|
+
disabled={busy} aria-label={t('syncflow.refreshSnapshots')} title={t('syncflow.refreshSnapshots')}
|
|
1317
|
+
aria-busy={cs.loadingSnapshots === true || undefined}>
|
|
1318
|
+
{cs.loadingSnapshots === true ? <Spinner /> : <RefreshIcon size={16} />}
|
|
1319
|
+
</button>
|
|
1320
|
+
<SyncProbeRow t={t} api={api} presentation="icon" remoteReady={ready} busy={busy}
|
|
1321
|
+
body={buildSyncChannelBody({ ...formOf(state), channel })} />
|
|
1323
1322
|
</span>
|
|
1324
1323
|
{/* 空态 = 选快照这一行的状态,跟控件同排(徽章),不另占一行:
|
|
1325
1324
|
此前它是 picker 下方单独一行的 hint,把下面整块内容整体顶低 4+10px。 */}
|
|
@@ -23,7 +23,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
23
23
|
import type { ReactNode } from 'react'
|
|
24
24
|
import type { TranslateNS } from '../client-types.ts'
|
|
25
25
|
import type { SyncApi, SyncProbeResult, SyncPushPayload } from './sync-api.ts'
|
|
26
|
-
import { Badge, Banner, Button } from '../common/ui.tsx'
|
|
26
|
+
import { Badge, Banner, Button, Spinner } from '../common/ui.tsx'
|
|
27
|
+
import { ProbeIcon } from '../common/Icon.tsx'
|
|
27
28
|
import { InfoHint } from '../common/InfoHint.tsx'
|
|
28
29
|
import { projectProbeResult } from './probe-view.ts'
|
|
29
30
|
import { toast } from '../common/toast-store.ts'
|
|
@@ -45,11 +46,12 @@ export interface SyncProbeRowProps {
|
|
|
45
46
|
* - 'card':动作行 + 内联结果横幅;
|
|
46
47
|
* - 'action':只渲染按钮 + ⓘ(嵌进别的动作行),结果走 toast。
|
|
47
48
|
*/
|
|
48
|
-
presentation?: 'card' | 'action'
|
|
49
|
+
presentation?: 'card' | 'action' | 'icon'
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = 'card' }: SyncProbeRowProps): ReactNode {
|
|
52
|
-
|
|
53
|
+
/** 两种「轻形态」都走 toast,只有渲染出来的控件不同 */
|
|
54
|
+
const actionMode = presentation === 'action' || presentation === 'icon'
|
|
53
55
|
const [result, setResult] = useState<SyncProbeResult | null>(null)
|
|
54
56
|
const [loading, setLoading] = useState(false)
|
|
55
57
|
const /** 传输层异常(与「自检结论 = 失败」分开:前者是宿主/网络没走通,后者是通道层给出的结论) */
|
|
@@ -102,6 +104,19 @@ export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = '
|
|
|
102
104
|
*/
|
|
103
105
|
const view = result === null ? null : projectProbeResult(result, t('probe.unsupported'))
|
|
104
106
|
|
|
107
|
+
const iconButton = (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className={css.iconBtn}
|
|
111
|
+
onClick={() => { void run() }}
|
|
112
|
+
disabled={busy || !remoteReady}
|
|
113
|
+
aria-label={t('probe.run')}
|
|
114
|
+
aria-busy={loading || undefined}
|
|
115
|
+
title={t('probe.run')}
|
|
116
|
+
>
|
|
117
|
+
{loading ? <Spinner /> : <ProbeIcon size={16} />}
|
|
118
|
+
</button>
|
|
119
|
+
)
|
|
105
120
|
const button = (
|
|
106
121
|
<Button onClick={() => { void run() }} loading={loading} disabled={busy || !remoteReady}>
|
|
107
122
|
{t('probe.run')}
|
|
@@ -113,9 +128,11 @@ export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = '
|
|
|
113
128
|
<>
|
|
114
129
|
{/* action 形态:直接摊进调用方自己的动作行(不再套一层 .actionRow),结果走 toast。
|
|
115
130
|
动作行里只留按钮:与同排其它按钮并排时,ⓘ 会被读成该按钮的开关(issue 121 用户要求移除)。 */}
|
|
116
|
-
{
|
|
117
|
-
?
|
|
118
|
-
:
|
|
131
|
+
{presentation === 'icon'
|
|
132
|
+
? iconButton
|
|
133
|
+
: actionMode
|
|
134
|
+
? <>{button}</>
|
|
135
|
+
: <div className={css.actionRow}>{button}{hintNode}</div>}
|
|
119
136
|
{!actionMode && error !== null && (
|
|
120
137
|
<ErrorBanner
|
|
121
138
|
error={redact(error)}
|
|
@@ -193,8 +193,8 @@ export function SyncSectionPickerDialog({ open, t, cmT, uiT, sectionName, catalo
|
|
|
193
193
|
{sections.length === 0 && <Banner kind="warn">{t('mode.atLeastOne')}</Banner>}
|
|
194
194
|
</Modal.Body>
|
|
195
195
|
<Modal.Footer>
|
|
196
|
-
{/*
|
|
197
|
-
|
|
196
|
+
{/* 2026-11-01 用户要求:删掉「改动即时生效并保存(关闭弹窗即完成)」那颗 ⓘ ——
|
|
197
|
+
它在解释一件用户无从选择的事(勾了就走,没有保存按钮),占位置不改决定。 */}
|
|
198
198
|
<Button variant="primary" onClick={onClose}>{t('mode.pickerDone')}</Button>
|
|
199
199
|
</Modal.Footer>
|
|
200
200
|
</Modal>
|
|
@@ -30,7 +30,7 @@ export const zh = {
|
|
|
30
30
|
'channel.gist': 'GitHub Gist',
|
|
31
31
|
'channel.perChannelHint': '每条通道(Git / WebDAV / 对象存储 / Gist)的自动同步、同步模式、加密与远端快照各自独立配置。',
|
|
32
32
|
// 通道入口卡(弹窗驱动:点按钮 → 弹窗内配置通道)
|
|
33
|
-
'channel.open': '
|
|
33
|
+
'channel.open': '配置',
|
|
34
34
|
'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV 服务器。',
|
|
35
35
|
// 断开通道配置(用户实测要求:配置过就必须能删掉,否则打不通的通道会永久占位)
|
|
36
36
|
'channel.expand': '展开通道详情',
|
|
@@ -106,7 +106,7 @@ export const zh = {
|
|
|
106
106
|
'repoPicker.empty': '没有可用的私有仓库',
|
|
107
107
|
'repoPicker.loadFailed': '仓库列表加载失败,可重试或直接手填地址。',
|
|
108
108
|
'repoPicker.updatedAt': '更新于 {time}',
|
|
109
|
-
'repoPicker.create': '+
|
|
109
|
+
'repoPicker.create': '+ 新建私有仓库',
|
|
110
110
|
'repoPicker.createTitle': '新建私有仓库',
|
|
111
111
|
'repoPicker.name': '仓库名',
|
|
112
112
|
'repoPicker.namePlaceholder': '如 dsh-configs',
|
|
@@ -180,7 +180,7 @@ export const zh = {
|
|
|
180
180
|
// 同步模式(默认快速导出 / 高级自定义导出)
|
|
181
181
|
'mode.title': '同步分区',
|
|
182
182
|
'mode.hint': '同步范围 = 勾选的分区集合(默认已勾选推荐分区,可随时改)。',
|
|
183
|
-
'mode.selectedCount': '已勾选 {n}
|
|
183
|
+
'mode.selectedCount': '已勾选 {n} / {total}',
|
|
184
184
|
'mode.sectionsHint': '默认可同步的是可移植分区;平台相关分区(MCP 服务器)与设备相关分区(插件文件、凭据状态、历史会话)只有你在这里主动勾选才参与同步。',
|
|
185
185
|
'mode.atLeastOne': '请至少勾选一个同步分区',
|
|
186
186
|
'mode.sectionPortable': '可移植',
|
|
@@ -198,7 +198,7 @@ export const zh = {
|
|
|
198
198
|
'mode.sessionsWorkspaceWarn': '已勾选历史对话,但没有勾选「工作区」分区:目标机上这些对话可能归属不到任何工作区而显示不出来。建议一并勾选「工作区」。',
|
|
199
199
|
'mode.gistSessionsWarn': '当前通道是 GitHub Gist,且勾选了「历史会话」:gist 会一次取回全部文件内容、单文件超限即截断、文件数还有上限,大分区同步可能失败或内容被截断。建议改用 Git / WebDAV / 对象存储通道同步历史会话,或取消勾选该分区。',
|
|
200
200
|
'mode.persistHint': '分区选择已保存到本机:自动同步和手动推送都会使用此配置(重启后仍生效)。',
|
|
201
|
-
'mode.choose': '
|
|
201
|
+
'mode.choose': '选择',
|
|
202
202
|
'mode.pickerTitle': '选择同步分区',
|
|
203
203
|
'mode.pickerHint': '改动即时生效并保存(关闭弹窗即完成)。',
|
|
204
204
|
'mode.pickerBack': '返回分区列表',
|
|
@@ -485,7 +485,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
485
485
|
'channel.gist': 'GitHub Gist',
|
|
486
486
|
'channel.perChannelHint': 'Auto sync, sync mode, encryption and remote snapshots are configured independently for each channel (Git / WebDAV / object storage / Gist).',
|
|
487
487
|
// Channel entry card (dialog-driven: click the button → configure the channel in the dialog)
|
|
488
|
-
'channel.open': 'Configure
|
|
488
|
+
'channel.open': 'Configure',
|
|
489
489
|
'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server.',
|
|
490
490
|
// Disconnect channel config (user report: once configured there was no way out)
|
|
491
491
|
'channel.expand': 'Expand channel details',
|
|
@@ -559,7 +559,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
559
559
|
'repoPicker.empty': 'No private repository available',
|
|
560
560
|
'repoPicker.loadFailed': 'Could not load the repository list — retry, or type the address below.',
|
|
561
561
|
'repoPicker.updatedAt': 'Updated {time}',
|
|
562
|
-
'repoPicker.create': '+ New private repository
|
|
562
|
+
'repoPicker.create': '+ New private repository',
|
|
563
563
|
'repoPicker.createTitle': 'Create a private repository',
|
|
564
564
|
'repoPicker.name': 'Repository name',
|
|
565
565
|
'repoPicker.namePlaceholder': 'e.g. dsh-configs',
|
|
@@ -593,24 +593,24 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
593
593
|
'syncflow.adoptHint': 'Check = import item; uncheck = skip',
|
|
594
594
|
'syncflow.needsReviewBadge': 'Needs decision',
|
|
595
595
|
'syncflow.empty': 'Remote snapshot matches local (no changes)',
|
|
596
|
-
'syncflow.listScope': '{review} item
|
|
596
|
+
'syncflow.listScope': '{review} {review, plural, item|items} listed for your decision · the other {auto} adopted by default',
|
|
597
597
|
'syncflow.keepLocalAll': 'Keep all current',
|
|
598
598
|
'syncflow.useRemoteAll': 'Use all backup',
|
|
599
|
-
'syncflow.bulkHint': 'Bulk applies to {n} item
|
|
599
|
+
'syncflow.bulkHint': 'Bulk applies to {n} {n, plural, item|items} above',
|
|
600
600
|
'syncflow.bulkErrorNote': 'error items need individual handling',
|
|
601
601
|
'syncflow.conflictUseLocal': 'Keep current',
|
|
602
602
|
'syncflow.conflictUseRemote': 'Use backup',
|
|
603
603
|
'syncflow.conflictUnresolved': 'Conflict item {itemId} has no resolution yet (keep current / use backup)',
|
|
604
604
|
'syncflow.diff': 'Diff',
|
|
605
|
-
'syncflow.importDone': 'Imported {n} section
|
|
605
|
+
'syncflow.importDone': 'Imported {n} {n, plural, section|sections}',
|
|
606
606
|
'syncflow.importFailed': 'Import failed (rolled back)',
|
|
607
607
|
'syncflow.importedSections': 'Written',
|
|
608
608
|
'syncflow.importWarnings': 'Import warnings',
|
|
609
609
|
'syncflow.rollback': 'Rollback',
|
|
610
610
|
'syncflow.rollbackDone': 'Rolled back',
|
|
611
611
|
// Honesty (2026-10): a partial rollback is never reported as "rolled back"
|
|
612
|
-
'syncflow.rollbackPartialToast': 'Rollback incomplete: {n} item
|
|
613
|
-
'syncflow.rollbackIncomplete': 'Rollback incomplete: {n} item
|
|
612
|
+
'syncflow.rollbackPartialToast': 'Rollback incomplete: {n} {n, plural, item|items} could not be restored',
|
|
613
|
+
'syncflow.rollbackIncomplete': 'Rollback incomplete: {n} {n, plural, item|items} could not be restored automatically (the configuration may be left partially changed)',
|
|
614
614
|
'syncflow.rollbackFailedItems': 'Items not rolled back (need manual handling)',
|
|
615
615
|
'syncflow.importRollbackIncomplete': 'Import failed and the overall rollback did not complete: some changes could not be restored (see the warnings below)',
|
|
616
616
|
'syncflow.needsRestart': 'Some changes require a DSH restart to take effect',
|
|
@@ -626,7 +626,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
626
626
|
// Sync mode (default quick export / advanced custom export)
|
|
627
627
|
'mode.title': 'Sync Sections',
|
|
628
628
|
'mode.hint': 'Sync scope = the ticked set of sections (recommended sections are ticked by default and can be changed any time).',
|
|
629
|
-
'mode.selectedCount': '{n} selected',
|
|
629
|
+
'mode.selectedCount': '{n} / {total} selected',
|
|
630
630
|
'mode.sectionsHint': 'Portable sections sync by default; platform-dependent sections (MCP servers) and device-specific sections (plugin files, credentials status, sessions) only participate when you explicitly tick them here.',
|
|
631
631
|
'mode.atLeastOne': 'Select at least one section to sync',
|
|
632
632
|
'mode.sectionPortable': 'Portable',
|
|
@@ -637,14 +637,14 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
637
637
|
'mode.sessionsLimit': 'How many recent sessions to sync',
|
|
638
638
|
'mode.sessionsLimitHint': 'Session content is sensitive, so only the latest 5 are included by default (0 = select the section but carry no sessions); the unit is a session directory, so all logs of one session travel together.',
|
|
639
639
|
'mode.sessionsPick': 'Pick specific conversations',
|
|
640
|
-
'mode.sessionsPickCount': '{n} conversation
|
|
640
|
+
'mode.sessionsPickCount': '{n} {n, plural, conversation|conversations} picked',
|
|
641
641
|
'mode.sessionsPickTitle': 'Pick conversations to sync',
|
|
642
642
|
'mode.sessionsPickHint': 'Only the picked conversations are synced (this wins over the count above). Picking none returns to the "latest N" mode.',
|
|
643
643
|
'mode.sessionsPickReset': 'Use "latest N" instead',
|
|
644
644
|
'mode.sessionsWorkspaceWarn': 'Conversations are selected but the "Workspaces" section is not: on the target machine they may not be attributed to any workspace and stay invisible. Selecting "Workspaces" too is recommended.',
|
|
645
645
|
'mode.gistSessionsWarn': 'The current channel is GitHub Gist and "Conversations" is selected: a gist returns all file content in one request, truncates any oversized single file, and caps the number of files, so syncing a large section may fail or be truncated. Use a Git / WebDAV / object-storage channel for conversations, or clear that section.',
|
|
646
646
|
'mode.persistHint': 'The section selection is saved locally: both auto sync and manual push use this configuration (persists across restarts).',
|
|
647
|
-
'mode.choose': 'Choose
|
|
647
|
+
'mode.choose': 'Choose',
|
|
648
648
|
'mode.pickerTitle': 'Choose Sync Sections',
|
|
649
649
|
'mode.pickerHint': 'Changes apply and are saved immediately (closing the dialog finishes the task).',
|
|
650
650
|
'mode.pickerBack': 'Back to sections',
|
|
@@ -699,7 +699,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
699
699
|
'autosync.partial': 'Partial',
|
|
700
700
|
// issue #79: surface the skip reason in the status line (previously info-level log only)
|
|
701
701
|
'autosync.skipReasonLabel': 'Skip reason',
|
|
702
|
-
'autosync.skipReasonConflictCount': '{reason} ({n} more section
|
|
702
|
+
'autosync.skipReasonConflictCount': '{reason} ({n} more {n, plural, section|sections} on the remote need confirmation — handle them on the sync page)',
|
|
703
703
|
'autosync.lastMessage': 'Last message',
|
|
704
704
|
'history.title': 'Sync History',
|
|
705
705
|
'history.empty': 'No sync history yet',
|
|
@@ -753,8 +753,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
753
753
|
'toast.snapshotsLoadFailed': 'Failed to load remote snapshots',
|
|
754
754
|
'toast.snapshotsRefreshed': 'Remote snapshot list refreshed',
|
|
755
755
|
// issue #59: remote snapshots that exist but cannot be read (skipping is defensive, never silent)
|
|
756
|
-
'toast.snapshotsUnreadable': '{n} remote snapshot
|
|
757
|
-
'snapshots.unreadableHint': '{n} remote snapshot
|
|
756
|
+
'toast.snapshotsUnreadable': '{n} remote {n, plural, snapshot|snapshots} could not be read (list is incomplete) — see the note below',
|
|
757
|
+
'snapshots.unreadableHint': '{n} remote {n, plural, snapshot|snapshots} cannot be parsed and are missing from this list: {files}',
|
|
758
758
|
// Page-level load failure (M-16: inline error state; loadError had no render point before)
|
|
759
759
|
'load.failed': 'Failed to load sync status',
|
|
760
760
|
'common.close': 'Close',
|
|
@@ -765,8 +765,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
765
765
|
'common.loading': 'Loading…',
|
|
766
766
|
'common.infoHint': 'Show description',
|
|
767
767
|
'common.cancel': 'Cancel',
|
|
768
|
-
'syncflow.importPartialToast': 'Partly successful: {n} item
|
|
769
|
-
'syncflow.importPartial': 'Partly successful: {n} item
|
|
768
|
+
'syncflow.importPartialToast': 'Partly successful: {n} {n, plural, item did|items did} not take effect — check the result details',
|
|
769
|
+
'syncflow.importPartial': 'Partly successful: {n} {n, plural, item did|items did} not take effect (the rest were written; nothing rolled back)',
|
|
770
770
|
'syncflow.ineffectiveItems': 'Items that did not take effect',
|
|
771
771
|
'history.remoteMovedAction': 'View remote snapshots in Library',
|
|
772
772
|
'syncflow.compat.excellent': 'Compatibility: excellent',
|
|
@@ -855,8 +855,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
855
855
|
'merge.reason.format': 'This content format does not support auto-merge',
|
|
856
856
|
'merge.reason.array': 'Arrays cannot be merged position by position',
|
|
857
857
|
'merge.reason.section': 'The whole section conflicts',
|
|
858
|
-
'merge.conflictCount': '{n} conflict
|
|
859
|
-
'merge.autoMergedCount': '{n} change
|
|
858
|
+
'merge.conflictCount': '{n} {n, plural, conflict|conflicts}',
|
|
859
|
+
'merge.autoMergedCount': '{n} {n, plural, change|changes} merged automatically',
|
|
860
860
|
'merge.col.local': 'Local',
|
|
861
861
|
'merge.col.remote': 'Remote',
|
|
862
862
|
'merge.col.ancestor': 'Ancestor',
|
|
@@ -872,8 +872,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
872
872
|
'merge.bulk.hint': '"Use auto-merge for all" only affects sections that really have an auto-merged result; "Leave conflicts for next sync" moves every section that needs your decision out of this run (nothing is written, the baseline does not advance) — they come back on the next sync.',
|
|
873
873
|
'merge.bulk.title': 'Bulk',
|
|
874
874
|
'merge.apply': 'Apply merge',
|
|
875
|
-
'merge.apply.summary': '{decided} of {needs} section
|
|
876
|
-
'merge.apply.willApply': '{n} section
|
|
875
|
+
'merge.apply.summary': '{decided} of {needs} {needs, plural, section|sections} decided',
|
|
876
|
+
'merge.apply.willApply': '{n} {n, plural, section|sections} will be applied now',
|
|
877
877
|
'merge.result.title': 'Merge result',
|
|
878
878
|
'merge.result.applied': 'Applied sections',
|
|
879
879
|
'merge.result.skipped': 'Skipped',
|
|
@@ -881,7 +881,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
881
881
|
'merge.result.restoreId': 'Restore point',
|
|
882
882
|
'merge.result.rolledBack': 'Rolled back in full',
|
|
883
883
|
'merge.result.rollbackIncomplete': 'Rollback was incomplete: some items were not restored (see warnings)',
|
|
884
|
-
'merge.result.pending': '{n} section
|
|
884
|
+
'merge.result.pending': '{n} {n, plural, section|sections} still need a decision (skip is not resolved, so they will come back on the next sync)',
|
|
885
885
|
'merge.result.rollback': 'Roll back',
|
|
886
886
|
'merge.result.rolledBackTo': 'Rolled back to {id}',
|
|
887
887
|
'merge.result.failed': '{n} failed',
|