dsh-config-manager 0.2.4 → 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 +1263 -1192
- 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 +10 -0
- package/lib/sync/git/git-transport.js +72 -14
- 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/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 +3 -2
- package/src/sync/git/git-transport.test.ts +168 -7
- package/src/sync/git/git-transport.ts +70 -14
- 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
|
@@ -637,7 +637,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
637
637
|
'recovery.decision.unknown': 'Unknown',
|
|
638
638
|
'recovery.verify.verdict.unknown': 'Unknown',
|
|
639
639
|
'recovery.currentState.safeMode': 'Safe mode active (config-modifying operations paused)',
|
|
640
|
-
'recovery.currentState.itemsOnly': 'Recovery
|
|
640
|
+
'recovery.currentState.itemsOnly': 'Recovery items need handling (write operations are not paused)',
|
|
641
641
|
'recovery.live.title': 'Running operations',
|
|
642
642
|
'recovery.incident.liveBlocked': 'This operation is still running, so it cannot be recovered or dismissed yet. Wait for it to finish.',
|
|
643
643
|
'recovery.incident.blockReason.noSnapshot': 'This operation has no trusted recovery snapshot bound to it, so recovery cannot run.',
|
|
@@ -791,24 +791,24 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
791
791
|
'sessions.dialog.open': 'Open session health check',
|
|
792
792
|
'sessions.dialog.lastScan.total': '{count} sessions',
|
|
793
793
|
'sessions.dialog.lastScan.issues': '{count} need attention',
|
|
794
|
-
'sessions.dialog.lastScan.repairs': '{count} repair record
|
|
794
|
+
'sessions.dialog.lastScan.repairs': '{count} repair {count, plural, record|records}',
|
|
795
795
|
'sessions.repair.action': 'Repair',
|
|
796
|
-
'sessions.repair.available': '{count} session
|
|
796
|
+
'sessions.repair.available': '{count} {count, plural, session|sessions} can be repaired in-app; click Repair on a row to preview, or use "Repair all" to process them in order.',
|
|
797
797
|
'sessions.repair.repairAll': 'Repair all ({count})',
|
|
798
798
|
'sessions.repair.allDone': 'Repaired {ok}; {failed} not repaired (per-item reasons below; repaired rows are removed from the list).',
|
|
799
799
|
'sessions.repair.batch.ok': 'Repaired',
|
|
800
800
|
'sessions.repair.batch.failed': 'Not repaired',
|
|
801
|
-
'sessions.repair.batch.dropped': 'dropped {rows} row
|
|
801
|
+
'sessions.repair.batch.dropped': 'dropped {rows} {rows, plural, row|rows}',
|
|
802
802
|
'sessions.repair.batch.transportError': 'Request failed: {message}',
|
|
803
803
|
'sessions.repair.actionLossless': 'Lossless',
|
|
804
804
|
'sessions.repair.actionLossy': 'Lossy',
|
|
805
805
|
'sessions.repair.applyLossy': 'Truncate and repair',
|
|
806
806
|
'sessions.repair.plan.lossy': 'This plan contains a LOSSY action: events after the truncation point are dropped (they cannot be recovered from the log, but the backup allows one-click rollback). Confirm before choosing "Truncate and repair".',
|
|
807
807
|
'sessions.repair.plan.title': 'Repair plan: {session}',
|
|
808
|
-
'sessions.repair.plan.summary': 'Drop {rows} duplicate event row
|
|
808
|
+
'sessions.repair.plan.summary': 'Drop {rows} duplicate event {rows, plural, row|rows} · {before} → {after}',
|
|
809
809
|
'sessions.repair.plan.backup': 'Backed up automatically first; roll back from Repair history',
|
|
810
810
|
'sessions.repair.apply': 'Apply repair',
|
|
811
|
-
'sessions.repair.applied': 'Repaired: dropped {rows} duplicate event
|
|
811
|
+
'sessions.repair.applied': 'Repaired: dropped {rows} duplicate {rows, plural, event|events}',
|
|
812
812
|
'sessions.repair.rolledBack': 'Rolled back to the pre-repair content',
|
|
813
813
|
'sessions.repair.rollback': 'Roll back',
|
|
814
814
|
'sessions.repair.rollbackConfirmTitle': 'Roll back this repair',
|
|
@@ -817,7 +817,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
817
817
|
'sessions.repair.history.title': 'Repair history (rollback available)',
|
|
818
818
|
'sessions.repair.history.applied': 'Repaired',
|
|
819
819
|
'sessions.repair.history.rolledBack': 'Rolled back',
|
|
820
|
-
'sessions.repair.history.dropped': 'dropped {rows} row
|
|
820
|
+
'sessions.repair.history.dropped': 'dropped {rows} {rows, plural, row|rows}',
|
|
821
821
|
'sessions.repair.history.unreadable': 'The repair ledger could not be read, so past repairs cannot be listed or rolled back here (the backup files are still in the session directory).',
|
|
822
822
|
// F-2: the official session.export channel (raw-log ZIP built by DSH, including descendants and attachments)
|
|
823
823
|
'sessions.zip.action': 'Download raw log (ZIP)',
|
|
@@ -886,13 +886,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
886
886
|
'rollback.switch.diverged': 'Stored preference is on, not active in this process',
|
|
887
887
|
'rollback.switch.apply': 'Apply now',
|
|
888
888
|
'rollback.ledger.summary': 'Ledger: {records} points · {size}',
|
|
889
|
-
'rollback.ledger.budget': '{evicted} automatic point
|
|
889
|
+
'rollback.ledger.budget': '{evicted} automatic {evicted, plural, point|points} evicted by the byte budget ({budget})',
|
|
890
890
|
'rollback.ledger.overBudget': 'The ledger is at {size}, over the {budget} byte budget, and no automatic point is left to evict (usually a single point is larger than the budget) — clean up manually to shrink it.',
|
|
891
891
|
'rollback.cleanup': 'Clean up ledger',
|
|
892
892
|
'rollback.cleanup.confirmTitle': 'Clean up the rollback-point ledger',
|
|
893
893
|
'rollback.cleanup.confirmMessage': 'Delete every automatic rollback point (rescue and protected points follow the checkbox below) and reclaim their content copies. Workspace files and session logs are not touched.',
|
|
894
894
|
'rollback.cleanup.includeGuards': 'Also delete rescue points (pre-rewind rescue points; deletion is irreversible)',
|
|
895
|
-
'rollback.cleanup.done': 'Deleted {records} rollback point
|
|
895
|
+
'rollback.cleanup.done': 'Deleted {records} rollback {records, plural, point|points}, reclaimed {size}',
|
|
896
896
|
'rollback.cleanup.none': 'Nothing to delete (no automatic points)',
|
|
897
897
|
'rollback.retention.hint': 'Automatic points: at most {perSession} per session and {total} in total; eviction only drops the oldest automatic points. Pre-rewind rescue points do not count against those two budgets, and those budgets do not evict them; but you cannot delete them, so they have their own two limits: at most {guardsPerSession} per session and {guards} in total (whichever limit is passed, the oldest rescue point is evicted).',
|
|
898
898
|
'rollback.autopoint.hint': 'Rollback points are kept automatically at the end of each turn: workspace snapshot plus session cursor (there is no manual capture entry).',
|
|
@@ -976,22 +976,22 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
976
976
|
'rollback.preview.reason.incomplete': 'This point is missing one of its two states: you must explicitly allow a partial rewind',
|
|
977
977
|
'rollback.preview.reason.hostRefused': 'The host refused this rewind (see the details below)',
|
|
978
978
|
'rollback.preview.segments': 'Tick the restorable segments only (there are just two states: workspace and session)',
|
|
979
|
-
'rollback.preview.overwrite': 'Overwrite {n} item
|
|
979
|
+
'rollback.preview.overwrite': 'Overwrite {n} {n, plural, item|items}',
|
|
980
980
|
'rollback.preview.different': '{n} of them differ in content',
|
|
981
|
-
'rollback.preview.missing': '{n} item
|
|
982
|
-
'rollback.preview.createdAfter': '{n} item
|
|
983
|
-
'rollback.preview.notice.skipped': '{n} item
|
|
981
|
+
'rollback.preview.missing': '{n} {n, plural, item|items} no longer exist (they will be recreated)',
|
|
982
|
+
'rollback.preview.createdAfter': '{n} {n, plural, item did|items did} not exist at that point (created afterwards; the rewind deletes only those that pass the safety rules)',
|
|
983
|
+
'rollback.preview.notice.skipped': '{n} {n, plural, item was|items were} skipped at capture time (not recorded; they are in the proof list, so they are outside the deletion test)',
|
|
984
984
|
'rollback.preview.notice.truncated': 'Capture was truncated by a limit (per-file / byte budget / file-count budget); whether anything is deleted also depends on whether the proof list (path index) is complete',
|
|
985
985
|
'rollback.preview.files.title': 'Files that will be rewound:',
|
|
986
986
|
'rollback.preview.select.foreign': 'Changed by another conversation',
|
|
987
987
|
'rollback.preview.select.toggle': '"{path}": check to restore it in this rewind',
|
|
988
|
-
'rollback.result.foreignSkipped': '{n} file
|
|
989
|
-
'rollback.result.merged': '{n} file
|
|
990
|
-
'rollback.result.conflict': '{n} file
|
|
991
|
-
'rollback.result.unavailable': '{n} file
|
|
992
|
-
'rollback.result.concurrent': '{n} file
|
|
993
|
-
'rollback.result.unclaimed': '{n} file
|
|
994
|
-
'rollback.result.selectionSkipped': '{n} file
|
|
988
|
+
'rollback.result.foreignSkipped': '{n} {n, plural, file was|files were} changed by another conversation after this point; this rewind left them untouched',
|
|
989
|
+
'rollback.result.merged': '{n} {n, plural, file|files} merged: the change from this conversation was reverted and the change from the other conversation was kept',
|
|
990
|
+
'rollback.result.conflict': '{n} {n, plural, file was|files were} changed on both sides in the same place and were left as they are — they need your decision',
|
|
991
|
+
'rollback.result.unavailable': '{n} {n, plural, file|files} could not be reverted safely (no common ancestor / binary / diff too large) and were left as they are',
|
|
992
|
+
'rollback.result.concurrent': '{n} {n, plural, file|files} changed again while this rewind was running and were left as they are',
|
|
993
|
+
'rollback.result.unclaimed': '{n} {n, plural, file|files} could not be attributed to any conversation (nothing was running when they changed) and were left as they are',
|
|
994
|
+
'rollback.result.selectionSkipped': '{n} {n, plural, file|files} skipped by your selection',
|
|
995
995
|
'rollback.preview.files.empty': 'The workspace already matches this rollback point, nothing to restore',
|
|
996
996
|
'rollback.preview.files.unavailable': 'The workspace state of this rollback point has no usable record (reason: {reason}), nothing to restore',
|
|
997
997
|
'rollback.preview.files.unavailableSession': 'The workspace state of this rollback point has no usable record (reason: {reason}); the session will still be rewound to before this point',
|
|
@@ -1023,14 +1023,14 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
1023
1023
|
'rollback.preview.result.code': 'code: {code}',
|
|
1024
1024
|
'rollback.preview.cursorBytes': 'Session log {now} → {target} bytes',
|
|
1025
1025
|
'rollback.group.label': 'Session: {session}',
|
|
1026
|
-
'rollback.group.count': '{n} point
|
|
1026
|
+
'rollback.group.count': '{n} {n, plural, point|points}',
|
|
1027
1027
|
'rollback.group.unknownSession': 'Unknown session',
|
|
1028
|
-
'rollback.row.files': '{n} file
|
|
1028
|
+
'rollback.row.files': '{n} {n, plural, file|files}',
|
|
1029
1029
|
'rollback.row.sessionCursor': 'Has session cursor',
|
|
1030
1030
|
'rollback.row.noSessionCursor': 'No session cursor (workspace only)',
|
|
1031
1031
|
'rollback.row.sessionShort': 'Session rewindable',
|
|
1032
1032
|
'rollback.row.workspaceShort': 'Workspace only',
|
|
1033
|
-
'rollback.row.notice.skipped': '{n} item
|
|
1033
|
+
'rollback.row.notice.skipped': '{n} {n, plural, item was|items were} skipped at capture time (not recorded; they are in the proof list and are never mistaken for newly created ones)',
|
|
1034
1034
|
'rollback.row.notice.truncated': 'This point hit the capture limit and was truncated (counts are still reported; deletion also depends on whether the proof list is complete)',
|
|
1035
1035
|
'rollback.row.notice.skippedShort': '{n} skipped',
|
|
1036
1036
|
'rollback.row.notice.truncatedShort': 'Truncated',
|
|
@@ -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>
|