@chatcode/cco-market 1.45.1
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/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics tab — issue #98: renders the profile composition check report
|
|
3
|
+
* served by the host route /dsh-market/check (see src/check.ts). Below the
|
|
4
|
+
* report sit the phase 2/3 action panels: a community-bundle ordering block
|
|
5
|
+
* (reorder locally with ↑/↓, POST to /dsh-market/bundle-order) and a
|
|
6
|
+
* snapshots & rollback panel (snapshot-panel.tsx) — collapsible, default
|
|
7
|
+
* collapsed, and lazy-fetching on first expand. The plugin presets panel
|
|
8
|
+
* ships in a later stacked PR.
|
|
9
|
+
*
|
|
10
|
+
* Read-only view of the loading-layer stack and the conflict surface: bundle
|
|
11
|
+
* order (official vs community), duplicate loader entry ids, peer dependency
|
|
12
|
+
* mismatches, multi-version core packages, overrides and orphan patches. The
|
|
13
|
+
* report
|
|
14
|
+
* shape mirrors the CheckReport interface in src/check.ts; it is re-declared
|
|
15
|
+
* here because the client bundle is built independently of the host tree.
|
|
16
|
+
*/
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
|
18
|
+
import { Button, IconChevronDownOutline14, IconChevronRightOutline14, IconLoadingOutline16, IconRefreshOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
19
|
+
import css from './Market.module.css'
|
|
20
|
+
import { api } from './market-data.ts'
|
|
21
|
+
import type { Translate } from './market-data.ts'
|
|
22
|
+
import { PresetPanel } from './preset-panel.tsx'
|
|
23
|
+
import { SnapshotPanel } from './snapshot-panel.tsx'
|
|
24
|
+
|
|
25
|
+
/** Mirrors BundleLayer in src/check.ts. */
|
|
26
|
+
interface BundleLayer {
|
|
27
|
+
name: string
|
|
28
|
+
source: string
|
|
29
|
+
kind: 'official' | 'community'
|
|
30
|
+
directory: string | null
|
|
31
|
+
patchPath: string | null
|
|
32
|
+
error: string | null
|
|
33
|
+
entries: string[]
|
|
34
|
+
parseError: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mirrors DuplicateId in src/check.ts. */
|
|
38
|
+
interface DuplicateId {
|
|
39
|
+
id: string
|
|
40
|
+
layers: string[]
|
|
41
|
+
count: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mirrors OverrideRow in src/check.ts. */
|
|
45
|
+
interface OverrideRow {
|
|
46
|
+
id: string
|
|
47
|
+
layer: string
|
|
48
|
+
overriddenLayers: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mirrors OrphanRow in src/check.ts. */
|
|
52
|
+
interface OrphanRow {
|
|
53
|
+
id: string
|
|
54
|
+
layer: string
|
|
55
|
+
reason: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mirrors PeerVerdict in src/check.ts. */
|
|
59
|
+
type PeerVerdict =
|
|
60
|
+
| {
|
|
61
|
+
kind: 'risk'
|
|
62
|
+
risk: { plugin: string; peer: string; range: string; resolved: string; direction: 'belowMin' | 'aboveMax' }
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
kind: 'warning'
|
|
66
|
+
warning: { plugin: string; peer: string; range: string; resolved: string; reason: 'aboveMax' | 'optional' }
|
|
67
|
+
}
|
|
68
|
+
| { kind: 'none' }
|
|
69
|
+
|
|
70
|
+
/** Mirrors PeerMismatch in src/check.ts. */
|
|
71
|
+
interface PeerMismatch {
|
|
72
|
+
plugin: string
|
|
73
|
+
name: string
|
|
74
|
+
range: string
|
|
75
|
+
resolved: string | null
|
|
76
|
+
satisfied: boolean | null
|
|
77
|
+
optional?: boolean
|
|
78
|
+
verdict?: PeerVerdict
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Mirrors MultiVersion in src/check.ts. */
|
|
82
|
+
interface MultiVersion {
|
|
83
|
+
name: string
|
|
84
|
+
versions: string[]
|
|
85
|
+
hoisted: string | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Mirrors CheckSummary in src/check.ts. */
|
|
89
|
+
interface CheckSummary {
|
|
90
|
+
ok: boolean
|
|
91
|
+
errors: string[]
|
|
92
|
+
warnings: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Mirrors OrderConflict in src/order.ts (top-level orderConflicts in CheckReport). */
|
|
96
|
+
interface OrderConflict {
|
|
97
|
+
name: string
|
|
98
|
+
reason: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Mirrors CheckReport in src/check.ts. */
|
|
102
|
+
interface CheckReport {
|
|
103
|
+
profile: string
|
|
104
|
+
scannedAt: number
|
|
105
|
+
bundles: BundleLayer[]
|
|
106
|
+
duplicates: DuplicateId[]
|
|
107
|
+
overrides: OverrideRow[]
|
|
108
|
+
orphans: OrphanRow[]
|
|
109
|
+
peerMismatches: PeerMismatch[]
|
|
110
|
+
multiVersion: MultiVersion[]
|
|
111
|
+
summary: CheckSummary
|
|
112
|
+
/** #98 phase 2: validateOrder result for the CURRENT bundle order, when the host emits it. */
|
|
113
|
+
orderConflicts?: OrderConflict[]
|
|
114
|
+
/** #98 opt: loader rows sharing one name — informational display only, never a conflict (review #109). */
|
|
115
|
+
duplicateNames?: Array<{ name: string; layers: string[]; count: number }>
|
|
116
|
+
/** #98 opt: LOOT-style suggested community order satisfying every rule. */
|
|
117
|
+
suggestedOrder?: { ok: true; order: string[] } | { ok: false; cycle: string[] } | null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A collapsible report section: header shows title + count + chevron; the
|
|
122
|
+
* body stays mounted (hidden via CSS when collapsed) so every block keeps
|
|
123
|
+
* its state. ALL blocks are collapsed by default — the summary strip above
|
|
124
|
+
* gives the overview, and a problem block's title is highlighted and its
|
|
125
|
+
* collapsed `overview` line shows the first issue, so nothing important is
|
|
126
|
+
* hidden. Expand a block to see its full content.
|
|
127
|
+
*/
|
|
128
|
+
function Section(props: {
|
|
129
|
+
title: string
|
|
130
|
+
count: number
|
|
131
|
+
empty: string
|
|
132
|
+
defaultOpen?: boolean
|
|
133
|
+
problem?: boolean
|
|
134
|
+
overview?: ReactNode
|
|
135
|
+
/** Render `children` even at count 0 (e.g. a zero-mismatch peer block whose
|
|
136
|
+
* informational disclosure must stay reachable). */
|
|
137
|
+
alwaysShowBody?: boolean
|
|
138
|
+
children: ReactNode
|
|
139
|
+
}) {
|
|
140
|
+
const { title, count, empty, defaultOpen, problem = true, overview, alwaysShowBody = false, children } = props
|
|
141
|
+
const [open, setOpen] = useState(defaultOpen ?? false)
|
|
142
|
+
const alert = problem && count > 0
|
|
143
|
+
return (
|
|
144
|
+
<section className={css.diagSection}>
|
|
145
|
+
<button type="button" className={css.collapseHead} onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
|
146
|
+
<span className={css.collapseIcon}>
|
|
147
|
+
{open ? <IconChevronDownOutline14 size={14} /> : <IconChevronRightOutline14 size={14} />}
|
|
148
|
+
</span>
|
|
149
|
+
{alert && <span className={css.diagAlert}>⚠</span>}
|
|
150
|
+
<span className={`${css.collapseTitle}${alert ? ` ${css.diagAlert}` : ''}`}>{title}</span>
|
|
151
|
+
<span className={css.diagCount}>({count})</span>
|
|
152
|
+
<span className={css.grow} />
|
|
153
|
+
</button>
|
|
154
|
+
{!open && overview !== undefined && <div className={css.sectionOverview}>{overview}</div>}
|
|
155
|
+
<div className={css.collapseBody} style={open ? undefined : { display: 'none' }}>
|
|
156
|
+
{count === 0 && !alwaysShowBody ? <div className={css.diagEmpty}>{empty}</div> : children}
|
|
157
|
+
</div>
|
|
158
|
+
</section>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A collapsible section that KEEPS its children mounted (hidden via CSS when
|
|
163
|
+
* collapsed) so the phase 3 panels below retain their loaded data and
|
|
164
|
+
* in-progress edits across collapses. Children mount from the start, but the
|
|
165
|
+
* panels only fetch when `open` first becomes true.
|
|
166
|
+
*/
|
|
167
|
+
function CollapsibleSection(props: { title: string; count?: number; open: boolean; onToggle: () => void; children: ReactNode }) {
|
|
168
|
+
const { title, count, open, onToggle, children } = props
|
|
169
|
+
return (
|
|
170
|
+
<section className={css.diagSection}>
|
|
171
|
+
<button type="button" className={css.collapseHead} onClick={onToggle} aria-expanded={open}>
|
|
172
|
+
<span className={css.collapseIcon}>
|
|
173
|
+
{open ? <IconChevronDownOutline14 size={14} /> : <IconChevronRightOutline14 size={14} />}
|
|
174
|
+
</span>
|
|
175
|
+
<span className={css.collapseTitle}>{title}</span>
|
|
176
|
+
{count !== undefined && <span className={css.diagCount}>({count})</span>}
|
|
177
|
+
<span className={css.grow} />
|
|
178
|
+
</button>
|
|
179
|
+
<div className={css.collapseBody} style={open ? undefined : { display: 'none' }}>
|
|
180
|
+
{children}
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
/** Map an orphan patch reason (src/check.ts) to a locale key for its badge. */
|
|
186
|
+
function orphanKindLabel(reason: string): string {
|
|
187
|
+
if (reason === 'insert is not an array') return 'orphanInsertNotArray'
|
|
188
|
+
if (reason === 'insert target not found') return 'orphanInsertTargetMissing'
|
|
189
|
+
if (reason === 'insert target is not a group') return 'orphanInsertTargetNotGroup'
|
|
190
|
+
if (reason === 'id required for non-insert patch') return 'orphanIdRequired'
|
|
191
|
+
if (reason === 'patch target not found') return 'orphanPatchTargetMissing'
|
|
192
|
+
if (reason.startsWith('name mismatch')) return 'orphanNameMismatch'
|
|
193
|
+
return 'orphanReasonOther'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Badge label for one risk-tier peer row (#201). */
|
|
197
|
+
function peerRiskLabel(peer: PeerMismatch, t: Translate): string {
|
|
198
|
+
return peer.verdict?.kind === 'risk' && peer.verdict.risk.direction === 'aboveMax'
|
|
199
|
+
? t('peerRiskAboveMax')
|
|
200
|
+
: t('peerRiskBelowMin')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Badge label for one warning-tier peer row (optional / aboveMax / legacy). */
|
|
204
|
+
function peerWarningLabel(peer: PeerMismatch, t: Translate): string {
|
|
205
|
+
if (peer.verdict?.kind !== 'warning') return t('checkUnsatisfied')
|
|
206
|
+
return peer.verdict.warning.reason === 'optional' ? t('peerWarnOptional') : t('peerWarnAboveMax')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Fetch and render the profile check report. Refetches on every mount, so
|
|
211
|
+
* switching tabs away and back re-runs the (cheap, read-only) analysis; the
|
|
212
|
+
* phase 3 panels below call `refresh()` after applying changes.
|
|
213
|
+
*/
|
|
214
|
+
export function Diagnostics(props: { t: Translate }) {
|
|
215
|
+
const { t } = props
|
|
216
|
+
const [report, setReport] = useState<CheckReport | null>(null)
|
|
217
|
+
const [error, setError] = useState<string | null>(null)
|
|
218
|
+
const [orderOpen, setOrderOpen] = useState(false)
|
|
219
|
+
const [explainOpen, setExplainOpen] = useState(false)
|
|
220
|
+
const [snapOpen, setSnapOpen] = useState(false)
|
|
221
|
+
const [presetOpen, setPresetOpen] = useState(false)
|
|
222
|
+
const [fixMsg, setFixMsg] = useState<string | null>(null)
|
|
223
|
+
/** The built AI-fix prompt when the clipboard path failed — rendered as a
|
|
224
|
+
* selectable text block so the user can still copy it manually. */
|
|
225
|
+
const [fixFallback, setFixFallback] = useState<string | null>(null)
|
|
226
|
+
/** Bump to re-run the /dsh-market/check fetch after an order/snapshot-restore apply. */
|
|
227
|
+
const [version, setVersion] = useState(0)
|
|
228
|
+
const refresh = useCallback(() => setVersion(v => v + 1), [])
|
|
229
|
+
|
|
230
|
+
// --- issue #98 phase 2 (step 1): community-bundle ordering ---------------
|
|
231
|
+
/** Community bundle names from the report, in declared order. */
|
|
232
|
+
const communityNames = useMemo(
|
|
233
|
+
() => report === null ? [] : report.bundles.filter(bundle => bundle.kind === 'community').map(bundle => bundle.name),
|
|
234
|
+
[report],
|
|
235
|
+
)
|
|
236
|
+
/** Local editing state: re-synced whenever the report (re)loads. */
|
|
237
|
+
const [order, setOrder] = useState<string[]>(communityNames)
|
|
238
|
+
const [orderMsg, setOrderMsg] = useState<string | null>(null)
|
|
239
|
+
const [orderErr, setOrderErr] = useState<string | null>(null)
|
|
240
|
+
const [orderBusy, setOrderBusy] = useState(false)
|
|
241
|
+
/** Current-vs-candidate composition diff from a rejected static-composition
|
|
242
|
+
* validation (#125 review): what the candidate would change, shown as a hint. */
|
|
243
|
+
const [orderDiff, setOrderDiff] = useState<{ overrides: number; orphans: number; duplicates: number } | null>(null)
|
|
244
|
+
/**
|
|
245
|
+
* Content identity of the last community order this draft synced to. A
|
|
246
|
+
* refresh() refetch returns a NEW array even when the order is unchanged,
|
|
247
|
+
* so a naive `setOrder(communityNames)` effect would wipe the user's
|
|
248
|
+
* in-progress drag/↑↓ edits on every unrelated re-check. Only resync when
|
|
249
|
+
* the report's community order actually CHANGED (apply order / snapshot
|
|
250
|
+
* restore) — an identical refetch keeps the draft (review M2).
|
|
251
|
+
*/
|
|
252
|
+
const syncedOrderRef = useRef<string[] | null>(null)
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
const synced = syncedOrderRef.current
|
|
255
|
+
const same = synced !== null
|
|
256
|
+
&& synced.length === communityNames.length
|
|
257
|
+
&& communityNames.every((name, i) => name === synced[i])
|
|
258
|
+
if (same) return
|
|
259
|
+
syncedOrderRef.current = communityNames
|
|
260
|
+
setOrder(communityNames)
|
|
261
|
+
}, [communityNames])
|
|
262
|
+
|
|
263
|
+
/** Swap one community bundle with its neighbour (-1 up, +1 down). */
|
|
264
|
+
const moveBundle = (index: number, delta: -1 | 1) => {
|
|
265
|
+
setOrder(prev => {
|
|
266
|
+
const next = [...prev]
|
|
267
|
+
const target = index + delta
|
|
268
|
+
if (target < 0 || target >= next.length) return prev
|
|
269
|
+
;[next[index], next[target]] = [next[target]!, next[index]!]
|
|
270
|
+
return next
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- drag & drop reordering (draft only — saved by 应用顺序 / Apply order) ---
|
|
275
|
+
/** Row being dragged (index into the local `order` draft). */
|
|
276
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
277
|
+
/** Row currently under the pointer, highlighted as the drop target. */
|
|
278
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
279
|
+
|
|
280
|
+
const onRowDragStart = (index: number) => (event: DragEvent<HTMLDivElement>) => {
|
|
281
|
+
if (orderBusy) {
|
|
282
|
+
event.preventDefault()
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
setDragIndex(index)
|
|
286
|
+
event.dataTransfer?.setData?.('text/plain', order[index] ?? '')
|
|
287
|
+
if (event.dataTransfer !== undefined) event.dataTransfer.effectAllowed = 'move'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const onRowDragOver = (index: number) => (event: DragEvent<HTMLDivElement>) => {
|
|
291
|
+
if (dragIndex === null || dragIndex === index) return
|
|
292
|
+
// preventDefault marks the row as a valid drop target (no auto-scroll).
|
|
293
|
+
event.preventDefault()
|
|
294
|
+
if (event.dataTransfer !== undefined) event.dataTransfer.dropEffect = 'move'
|
|
295
|
+
setDragOverIndex(index)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const onRowDragLeave = (index: number) => () => {
|
|
299
|
+
setDragOverIndex(prev => prev === index ? null : prev)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const onRowDrop = (index: number) => (event: DragEvent<HTMLDivElement>) => {
|
|
303
|
+
event.preventDefault()
|
|
304
|
+
const from = dragIndex
|
|
305
|
+
setDragIndex(null)
|
|
306
|
+
setDragOverIndex(null)
|
|
307
|
+
if (from === null || from === index) return
|
|
308
|
+
// Reorder the LOCAL draft only; the host is told via 应用顺序 / Apply order.
|
|
309
|
+
setOrder(prev => {
|
|
310
|
+
const next = [...prev]
|
|
311
|
+
const [moved] = next.splice(from, 1)
|
|
312
|
+
next.splice(index, 0, moved!)
|
|
313
|
+
return next
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const onRowDragEnd = () => {
|
|
318
|
+
setDragIndex(null)
|
|
319
|
+
setDragOverIndex(null)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** POST the current community order; the host statically validates the
|
|
323
|
+
* candidate composition (dry-run replay) before writing. */
|
|
324
|
+
const applyOrder = (target?: string[]) => {
|
|
325
|
+
if (orderBusy) return
|
|
326
|
+
setOrderBusy(true)
|
|
327
|
+
setOrderMsg(null)
|
|
328
|
+
setOrderErr(null)
|
|
329
|
+
setOrderDiff(null)
|
|
330
|
+
fetch(api('/dsh-market/bundle-order'), {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
headers: { 'content-type': 'application/json' },
|
|
333
|
+
body: JSON.stringify({ order: target ?? order }),
|
|
334
|
+
})
|
|
335
|
+
.then(async (res) => {
|
|
336
|
+
const body = (await res.json().catch(() => null)) as {
|
|
337
|
+
ok?: unknown
|
|
338
|
+
error?: unknown
|
|
339
|
+
trial?: {
|
|
340
|
+
errors?: Array<{ layer?: unknown; message?: unknown }>
|
|
341
|
+
warnings?: unknown[]
|
|
342
|
+
diff?: { overrides?: unknown[]; orphans?: unknown[]; duplicates?: unknown[] }
|
|
343
|
+
}
|
|
344
|
+
} | null
|
|
345
|
+
if (!res.ok || body?.ok !== true) {
|
|
346
|
+
// A rejected static-composition validation (422) carries the
|
|
347
|
+
// current-vs-candidate diff — surface what the candidate would
|
|
348
|
+
// change as an informational hint (issue #125 review).
|
|
349
|
+
const diff = body?.trial?.diff
|
|
350
|
+
const overrides = diff?.overrides?.length ?? 0
|
|
351
|
+
const orphans = diff?.orphans?.length ?? 0
|
|
352
|
+
const duplicates = diff?.duplicates?.length ?? 0
|
|
353
|
+
setOrderDiff(overrides + orphans + duplicates > 0 ? { overrides, orphans, duplicates } : null)
|
|
354
|
+
const firstMessage = body?.trial?.errors?.[0]?.message
|
|
355
|
+
setOrderErr(body?.trial !== undefined
|
|
356
|
+
? t('orderTrialFail').replace('{0}', firstMessage !== undefined ? String(firstMessage) : '')
|
|
357
|
+
: String(body?.error ?? `HTTP ${String(res.status)}`))
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
setOrderDiff(null)
|
|
361
|
+
setOrderMsg(t('orderApplied'))
|
|
362
|
+
// Refetch the report so communityNames / the ordering draft reflect
|
|
363
|
+
// the applied order.
|
|
364
|
+
refresh()
|
|
365
|
+
})
|
|
366
|
+
.catch((err: unknown) => setOrderErr(err instanceof Error ? err.message : String(err)))
|
|
367
|
+
.finally(() => setOrderBusy(false))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
let live = true
|
|
372
|
+
// Do NOT null the report here: a refresh() (manual re-check, or after an
|
|
373
|
+
// order/snapshot-restore apply) must keep the previous data visible and
|
|
374
|
+
// must not clobber the in-progress ordering draft, which re-syncs from
|
|
375
|
+
// communityNames only when the report actually changes (review M2).
|
|
376
|
+
setError(null)
|
|
377
|
+
fetch(api('/dsh-market/check'), { cache: 'no-store' })
|
|
378
|
+
.then(async (res) => {
|
|
379
|
+
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
|
|
380
|
+
const body = (await res.json()) as CheckReport
|
|
381
|
+
if (live) setReport(body)
|
|
382
|
+
})
|
|
383
|
+
.catch((err: unknown) => {
|
|
384
|
+
if (live) setError(err instanceof Error ? err.message : String(err))
|
|
385
|
+
})
|
|
386
|
+
return () => { live = false }
|
|
387
|
+
}, [version])
|
|
388
|
+
|
|
389
|
+
if (error !== null) {
|
|
390
|
+
return <div className={css.err}>{t('checkLoadFail')}{error}</div>
|
|
391
|
+
}
|
|
392
|
+
if (report === null) {
|
|
393
|
+
return (
|
|
394
|
+
<div className={css.loading}>
|
|
395
|
+
<span className={css.spin}><IconLoadingOutline16 size={22} /></span>
|
|
396
|
+
{t('checkLoading')}
|
|
397
|
+
</div>
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const summary = report.summary
|
|
402
|
+
const suggested = report.suggestedOrder ?? null
|
|
403
|
+
// #201: confirmed mismatches are tiered by the server-side classifyPeer
|
|
404
|
+
// verdict instead of the old binary `satisfied === false` split. A missing
|
|
405
|
+
// verdict (older host) keeps the legacy behavior: confirmed = warning tier.
|
|
406
|
+
const peerRisk = report.peerMismatches.filter(peer => peer.satisfied === false && peer.verdict?.kind === 'risk')
|
|
407
|
+
const peerWarning = report.peerMismatches.filter(peer => peer.satisfied === false && (peer.verdict === undefined || peer.verdict.kind === 'warning'))
|
|
408
|
+
// Classifier `none` (unparseable / * prerelease artifacts) and unevaluable
|
|
409
|
+
// rows share the informational display tier; true rows stay satisfied.
|
|
410
|
+
const peerInfo = report.peerMismatches.filter(peer =>
|
|
411
|
+
(peer.satisfied === false && peer.verdict?.kind === 'none') || peer.satisfied === null)
|
|
412
|
+
const peerSatisfied = report.peerMismatches.filter(peer => peer.satisfied === true)
|
|
413
|
+
// Category counts for the overview strip: conflicts / peer tiers / order.
|
|
414
|
+
// Conflicts = HARD duplicate loader entries only; same-name rows are
|
|
415
|
+
// informational and stay out of the conflict count (review #109).
|
|
416
|
+
const catConflict = report.duplicates.length
|
|
417
|
+
const catRisk = peerRisk.length
|
|
418
|
+
const catWarn = peerWarning.length
|
|
419
|
+
const catInfo = peerInfo.length
|
|
420
|
+
const catMulti = report.multiVersion.length
|
|
421
|
+
const catOrder = report.orderConflicts?.length ?? 0
|
|
422
|
+
const anyIssue = catConflict + catRisk + catWarn + catInfo + catMulti + catOrder > 0
|
|
423
|
+
// AI-fix only shows for HARD issues — boot errors, duplicate entries, or a
|
|
424
|
+
// directional peer RISK. Optional peers, aboveMax without an explicit
|
|
425
|
+
// bound, and unknown rows are warning/info and never nudge an agent into
|
|
426
|
+
// risky changes without a clear problem (#201, conservative UX).
|
|
427
|
+
const hasHardIssues = summary.errors.length > 0
|
|
428
|
+
|| report.duplicates.length > 0
|
|
429
|
+
|| catRisk > 0
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Build the AI-fix prompt (structured errors / duplicates / peer risks /
|
|
433
|
+
* order conflicts + scope) and copy it to the clipboard. The user pastes it
|
|
434
|
+
* into a new conversation and decides whether to send — the agent never runs
|
|
435
|
+
* automatically.
|
|
436
|
+
* (A previous auto-open/prefill attempt was dropped: it was unreliable
|
|
437
|
+
* across host versions, so plain copy + toast is the contract.)
|
|
438
|
+
*/
|
|
439
|
+
const startAgentFix = () => {
|
|
440
|
+
const lines: string[] = []
|
|
441
|
+
lines.push(t('aiFixIntro').replace('{0}', report.profile))
|
|
442
|
+
lines.push('')
|
|
443
|
+
// Self-identification guard FIRST: the prompt must make the receiving
|
|
444
|
+
// agent detect whether it is itself the harness running this profile.
|
|
445
|
+
// If it is, mutating the live composition — or upgrading/restarting the
|
|
446
|
+
// harness or core packages, or reinstalling deps — would kill the very
|
|
447
|
+
// session doing the fix, so those actions are forbidden and only a plan
|
|
448
|
+
// to run externally is produced. When the agent is NOT the target harness
|
|
449
|
+
// (a separate process), the conservative in-place scope below applies.
|
|
450
|
+
lines.push(t('aiFixDetect'))
|
|
451
|
+
lines.push('')
|
|
452
|
+
lines.push(t('aiFixIfSelf'))
|
|
453
|
+
lines.push('')
|
|
454
|
+
// Structured prompt only (#201): boot errors, duplicate loader ids and
|
|
455
|
+
// directional peer RISK rows. summary.warnings is no longer dumped — its
|
|
456
|
+
// peer lines contain the whole 30+ warning/info noise this issue untangles.
|
|
457
|
+
const errorLines = summary.errors.filter(line => !line.startsWith('duplicate loader entry id'))
|
|
458
|
+
if (errorLines.length > 0) {
|
|
459
|
+
lines.push(`${t('checkErrors')}:`)
|
|
460
|
+
for (const e of errorLines) lines.push(`- ${e}`)
|
|
461
|
+
lines.push('')
|
|
462
|
+
}
|
|
463
|
+
if (report.duplicates.length > 0) {
|
|
464
|
+
lines.push(`${t('checkDuplicates')}:`)
|
|
465
|
+
for (const dup of report.duplicates) lines.push(`- ${dup.id} × ${dup.count} (${dup.layers.join(' / ')})`)
|
|
466
|
+
lines.push('')
|
|
467
|
+
}
|
|
468
|
+
if (peerRisk.length > 0) {
|
|
469
|
+
lines.push(`${t('checkPeerRisk')}:`)
|
|
470
|
+
for (const peer of peerRisk) {
|
|
471
|
+
const direction = peer.verdict?.kind === 'risk' && peer.verdict.risk.direction === 'aboveMax'
|
|
472
|
+
? t('peerRiskAboveMax')
|
|
473
|
+
: t('peerRiskBelowMin')
|
|
474
|
+
lines.push(`- ${peer.plugin} → ${peer.name}@${peer.range} (${t('checkResolved')}: ${peer.resolved ?? '—'}, ${direction})`)
|
|
475
|
+
}
|
|
476
|
+
lines.push('')
|
|
477
|
+
}
|
|
478
|
+
if ((report.orderConflicts ?? []).length > 0) {
|
|
479
|
+
lines.push(`${t('catOrder')}:`)
|
|
480
|
+
for (const c of report.orderConflicts ?? []) lines.push(`- ${c.name}: ${c.reason}`)
|
|
481
|
+
lines.push('')
|
|
482
|
+
}
|
|
483
|
+
lines.push(t('aiFixScope'))
|
|
484
|
+
lines.push('')
|
|
485
|
+
lines.push(t('aiFixConservative'))
|
|
486
|
+
const prompt = lines.join('\n')
|
|
487
|
+
|
|
488
|
+
// Clipboard-first; on any failure (missing API or a rejected promise) show
|
|
489
|
+
// the prompt in a selectable block so the user can still copy it by hand —
|
|
490
|
+
// a bare "clipboard unavailable" message left nothing to copy.
|
|
491
|
+
setFixMsg(null)
|
|
492
|
+
setFixFallback(null)
|
|
493
|
+
const fallback = () => setFixFallback(prompt)
|
|
494
|
+
if (typeof navigator.clipboard?.writeText === 'function') {
|
|
495
|
+
navigator.clipboard.writeText(prompt)
|
|
496
|
+
.then(() => setFixMsg(t('aiFixCopied')))
|
|
497
|
+
.catch(fallback)
|
|
498
|
+
} else {
|
|
499
|
+
fallback()
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
<div className={css.diagPage}>
|
|
505
|
+
<div className={css.diagSummary}>
|
|
506
|
+
<span className={summary.ok ? css.okState : css.err}>
|
|
507
|
+
<StateDot state={summary.ok ? 'done' : 'error'} size={8} />
|
|
508
|
+
{summary.ok ? (anyIssue ? t('checkIssues') : t('diagOkAll')) : t('checkIssues')}
|
|
509
|
+
</span>
|
|
510
|
+
<span className={css.diagSummaryItem} title={t('checkDuplicates')}>
|
|
511
|
+
<StateDot state="error" size={8} />{t('catConflict')}: {catConflict}
|
|
512
|
+
</span>
|
|
513
|
+
<span className={css.diagSummaryItem} title={t('checkPeerRisk')}>
|
|
514
|
+
<StateDot state="error" size={8} />{t('catRisk')}: {catRisk}
|
|
515
|
+
</span>
|
|
516
|
+
<span className={css.diagSummaryItem} title={t('checkPeerWarning')}>
|
|
517
|
+
<StateDot state="warning" size={8} />{t('catWarn')}: {catWarn}
|
|
518
|
+
</span>
|
|
519
|
+
<span className={css.diagSummaryItem} title={t('checkPeerInfoTier')}>
|
|
520
|
+
<StateDot state="ongoing" size={8} />{t('catInfo')}: {catInfo}
|
|
521
|
+
</span>
|
|
522
|
+
<span className={css.diagSummaryItem} title={t('checkMultiVersion')}>
|
|
523
|
+
<StateDot state="warning" size={8} />{t('checkMultiVersion')}: {catMulti}
|
|
524
|
+
</span>
|
|
525
|
+
<span className={css.diagSummaryItem} title={t('checkOrderTip')}>
|
|
526
|
+
<StateDot state="warning" size={8} />{t('catOrder')}: {catOrder}
|
|
527
|
+
</span>
|
|
528
|
+
<span className={css.grow} />
|
|
529
|
+
{hasHardIssues && (
|
|
530
|
+
<Button variant="outline" size="sm" onClick={startAgentFix} title={t('aiFixHint')}>
|
|
531
|
+
{t('aiFix')}
|
|
532
|
+
</Button>
|
|
533
|
+
)}
|
|
534
|
+
<Button variant="ghost" size="sm" aria-label={t('checkRefresh')} onClick={refresh}>
|
|
535
|
+
<IconRefreshOutline14 size={14} />
|
|
536
|
+
</Button>
|
|
537
|
+
<span className={css.diagSummaryMeta} title={report.profile}>{t('checkProfile')}: {report.profile}</span>
|
|
538
|
+
<span className={css.diagSummaryMeta}>{new Date(report.scannedAt).toLocaleString()}</span>
|
|
539
|
+
</div>
|
|
540
|
+
{fixMsg !== null && <div className={css.okState}>{fixMsg}</div>}
|
|
541
|
+
{fixFallback !== null && (
|
|
542
|
+
<div className={css.fixFallback}>
|
|
543
|
+
<p className={css.panelNote}>{t('aiFixFail')}</p>
|
|
544
|
+
<textarea
|
|
545
|
+
readOnly
|
|
546
|
+
rows={10}
|
|
547
|
+
className={css.fixFallbackText}
|
|
548
|
+
value={fixFallback}
|
|
549
|
+
onFocus={e => e.currentTarget.select()}
|
|
550
|
+
/>
|
|
551
|
+
</div>
|
|
552
|
+
)}
|
|
553
|
+
|
|
554
|
+
<CollapsibleSection title={t('diagExplain')} open={explainOpen} onToggle={() => setExplainOpen(o => !o)}>
|
|
555
|
+
<p className={css.panelNote}>{t('diagExplainText')}</p>
|
|
556
|
+
<div className={css.diagList}>
|
|
557
|
+
<div className={css.spec}>{t('diagTermBundle')}</div>
|
|
558
|
+
<div className={css.spec}>{t('diagTermEntry')}</div>
|
|
559
|
+
<div className={css.spec}>{t('diagTermPeer')}</div>
|
|
560
|
+
<div className={css.spec}>{t('diagTermShadow')}</div>
|
|
561
|
+
<div className={css.spec}>{t('diagTermOrphan')}</div>
|
|
562
|
+
<div className={css.spec}>{t('diagTermOrder')}</div>
|
|
563
|
+
</div>
|
|
564
|
+
</CollapsibleSection>
|
|
565
|
+
|
|
566
|
+
<Section
|
|
567
|
+
title={t('checkErrors')}
|
|
568
|
+
count={summary.errors.length}
|
|
569
|
+
empty={t('checkErrorsEmpty')}
|
|
570
|
+
overview={summary.errors.length > 0 ? summary.errors[0] : undefined}
|
|
571
|
+
>
|
|
572
|
+
<div className={css.diagList}>
|
|
573
|
+
{summary.errors.map((line, i) => (
|
|
574
|
+
<div key={i} className={css.err}>{line}</div>
|
|
575
|
+
))}
|
|
576
|
+
</div>
|
|
577
|
+
</Section>
|
|
578
|
+
|
|
579
|
+
<Section
|
|
580
|
+
title={t('checkWarnings')}
|
|
581
|
+
count={summary.warnings.length}
|
|
582
|
+
empty={t('checkWarningsEmpty')}
|
|
583
|
+
overview={summary.warnings.length > 0 ? summary.warnings[0] : undefined}
|
|
584
|
+
>
|
|
585
|
+
<div className={css.diagList}>
|
|
586
|
+
{summary.warnings.map((line, i) => (
|
|
587
|
+
<div key={i} className={css.warnLine}><span>{line}</span></div>
|
|
588
|
+
))}
|
|
589
|
+
</div>
|
|
590
|
+
</Section>
|
|
591
|
+
|
|
592
|
+
<Section
|
|
593
|
+
title={t('checkBundles')}
|
|
594
|
+
count={report.bundles.length}
|
|
595
|
+
empty={t('checkBundlesEmpty')}
|
|
596
|
+
problem={false}
|
|
597
|
+
overview={
|
|
598
|
+
<span>
|
|
599
|
+
{t('checkOfficial')} × {report.bundles.filter(b => b.kind === 'official').length}
|
|
600
|
+
{' · '}
|
|
601
|
+
{t('checkCommunity')} × {report.bundles.filter(b => b.kind === 'community').length}
|
|
602
|
+
</span>
|
|
603
|
+
}
|
|
604
|
+
>
|
|
605
|
+
{report.bundles.map((bundle, i) => (
|
|
606
|
+
<div key={bundle.name} className={css.diagBundle}>
|
|
607
|
+
<div className={css.diagRow}>
|
|
608
|
+
<span className={css.diagIndex}>{i + 1}</span>
|
|
609
|
+
<span className={css.diagArrow}>→</span>
|
|
610
|
+
<span className={css.nm}>{bundle.name}</span>
|
|
611
|
+
<span className={bundle.kind === 'official' ? css.diagBadgeOfficial : css.diagBadgeCommunity}>
|
|
612
|
+
{bundle.kind === 'official' ? t('checkOfficial') : t('checkCommunity')}
|
|
613
|
+
</span>
|
|
614
|
+
{bundle.error !== null && <span className={css.err}>{bundle.error}</span>}
|
|
615
|
+
{bundle.parseError !== null && <span className={css.err}>{t('checkPatch')}: {bundle.parseError}</span>}
|
|
616
|
+
</div>
|
|
617
|
+
<div className={css.diagMeta}>
|
|
618
|
+
<span className={css.diagKey}>{t('checkSource')}</span>
|
|
619
|
+
<code className={css.spec}>{bundle.source}</code>
|
|
620
|
+
</div>
|
|
621
|
+
<div className={css.diagMeta}>
|
|
622
|
+
<span className={css.diagKey}>{t('checkEntries')}</span>
|
|
623
|
+
<code className={css.spec}>{bundle.entries.length > 0 ? bundle.entries.join(', ') : '—'}</code>
|
|
624
|
+
</div>
|
|
625
|
+
{bundle.directory !== null && (
|
|
626
|
+
<div className={css.diagMeta}>
|
|
627
|
+
<span className={css.diagKey}>{t('checkDir')}</span>
|
|
628
|
+
<code className={css.spec}>{bundle.directory}</code>
|
|
629
|
+
</div>
|
|
630
|
+
)}
|
|
631
|
+
{bundle.patchPath !== null && (
|
|
632
|
+
<div className={css.diagMeta}>
|
|
633
|
+
<span className={css.diagKey}>{t('checkPatch')}</span>
|
|
634
|
+
<code className={css.spec}>{bundle.patchPath}</code>
|
|
635
|
+
</div>
|
|
636
|
+
)}
|
|
637
|
+
</div>
|
|
638
|
+
))}
|
|
639
|
+
</Section>
|
|
640
|
+
|
|
641
|
+
<Section
|
|
642
|
+
title={t('checkDuplicates')}
|
|
643
|
+
count={report.duplicates.length}
|
|
644
|
+
empty={t('checkDuplicatesEmpty')}
|
|
645
|
+
overview={report.duplicates.length > 0 ? `${report.duplicates[0]?.id} × ${report.duplicates[0]?.count}` : undefined}
|
|
646
|
+
>
|
|
647
|
+
<div className={css.diagList}>
|
|
648
|
+
{report.duplicates.map(dup => (
|
|
649
|
+
<div key={dup.id} className={css.diagRow}>
|
|
650
|
+
<code className={css.diagVal}>{dup.id}</code>
|
|
651
|
+
<span className={css.err}>× {dup.count}</span>
|
|
652
|
+
<span className={css.spec}>{dup.layers.join(' / ')}</span>
|
|
653
|
+
</div>
|
|
654
|
+
))}
|
|
655
|
+
</div>
|
|
656
|
+
</Section>
|
|
657
|
+
|
|
658
|
+
<Section
|
|
659
|
+
title={t('checkPeerRisk')}
|
|
660
|
+
count={peerRisk.length}
|
|
661
|
+
empty={t('checkPeerRiskEmpty')}
|
|
662
|
+
overview={peerRisk.length > 0
|
|
663
|
+
? `${peerRisk[0].plugin} → ${peerRisk[0].name}@${peerRisk[0].range}(${t('checkResolved')}: ${peerRisk[0].resolved ?? '—'})`
|
|
664
|
+
: undefined}
|
|
665
|
+
>
|
|
666
|
+
<div className={css.diagList}>
|
|
667
|
+
{peerRisk.map((peer, i) => (
|
|
668
|
+
<div key={i} className={css.diagRow}>
|
|
669
|
+
<code className={css.diagVal}>{peer.name}</code>
|
|
670
|
+
<span className={css.nm}>{peer.plugin}</span>
|
|
671
|
+
<span className={css.spec}>{t('checkRange')}: {peer.range}</span>
|
|
672
|
+
<span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
|
|
673
|
+
<span className={css.diagBadgeShadow}>{peerRiskLabel(peer, t)}</span>
|
|
674
|
+
</div>
|
|
675
|
+
))}
|
|
676
|
+
</div>
|
|
677
|
+
</Section>
|
|
678
|
+
|
|
679
|
+
<Section
|
|
680
|
+
title={t('checkPeerWarning')}
|
|
681
|
+
count={peerWarning.length}
|
|
682
|
+
empty={t('checkPeerWarningEmpty')}
|
|
683
|
+
overview={peerWarning.length > 0
|
|
684
|
+
? `${peerWarning[0].plugin} → ${peerWarning[0].name}@${peerWarning[0].range}(${peerWarningLabel(peerWarning[0], t)})`
|
|
685
|
+
: undefined}
|
|
686
|
+
>
|
|
687
|
+
<div className={css.diagList}>
|
|
688
|
+
{peerWarning.map((peer, i) => (
|
|
689
|
+
<div key={i} className={css.diagRow}>
|
|
690
|
+
<code className={css.diagVal}>{peer.name}</code>
|
|
691
|
+
<span className={css.nm}>{peer.plugin}</span>
|
|
692
|
+
<span className={css.spec}>{t('checkRange')}: {peer.range}</span>
|
|
693
|
+
<span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
|
|
694
|
+
<span className={css.diagBadgeWarn}>{peerWarningLabel(peer, t)}</span>
|
|
695
|
+
</div>
|
|
696
|
+
))}
|
|
697
|
+
</div>
|
|
698
|
+
</Section>
|
|
699
|
+
|
|
700
|
+
<Section
|
|
701
|
+
title={t('checkPeerInfoTier')}
|
|
702
|
+
count={peerInfo.length}
|
|
703
|
+
empty={t('checkPeerInfoTierEmpty')}
|
|
704
|
+
problem={false}
|
|
705
|
+
overview={peerInfo.length > 0
|
|
706
|
+
? `${peerInfo[0].plugin} → ${peerInfo[0].name}@${peerInfo[0].range}`
|
|
707
|
+
: undefined}
|
|
708
|
+
>
|
|
709
|
+
<div className={css.diagList}>
|
|
710
|
+
{peerInfo.map((peer, i) => (
|
|
711
|
+
<div key={i} className={css.diagRow}>
|
|
712
|
+
<code className={css.diagVal}>{peer.name}</code>
|
|
713
|
+
<span className={css.nm}>{peer.plugin}</span>
|
|
714
|
+
<span className={css.spec}>{t('checkRange')}: {peer.range}</span>
|
|
715
|
+
<span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
|
|
716
|
+
{peer.satisfied === null
|
|
717
|
+
? <span className={css.diagBadgeInfo}>{t('checkUnknown')}</span>
|
|
718
|
+
: <span className={css.diagBadgeInfo}>{t('peerInfoUnverified')}</span>}
|
|
719
|
+
</div>
|
|
720
|
+
))}
|
|
721
|
+
</div>
|
|
722
|
+
</Section>
|
|
723
|
+
|
|
724
|
+
<Section
|
|
725
|
+
title={t('checkPeerSatisfied')}
|
|
726
|
+
count={peerSatisfied.length}
|
|
727
|
+
empty={t('checkPeerSatisfiedEmpty')}
|
|
728
|
+
problem={false}
|
|
729
|
+
overview={peerSatisfied.length > 0
|
|
730
|
+
? `${peerSatisfied[0].plugin} → ${peerSatisfied[0].name}@${peerSatisfied[0].range}`
|
|
731
|
+
: undefined}
|
|
732
|
+
>
|
|
733
|
+
<div className={css.diagList}>
|
|
734
|
+
{peerSatisfied.map((peer, i) => (
|
|
735
|
+
<div key={i} className={css.diagRow}>
|
|
736
|
+
<code className={css.diagVal}>{peer.name}</code>
|
|
737
|
+
<span className={css.nm}>{peer.plugin}</span>
|
|
738
|
+
<span className={css.spec}>{t('checkRange')}: {peer.range}</span>
|
|
739
|
+
<span className={css.spec}>{t('checkResolved')}: {peer.resolved ?? '—'}</span>
|
|
740
|
+
<span className={css.okState}>{t('checkSatisfied')}</span>
|
|
741
|
+
</div>
|
|
742
|
+
))}
|
|
743
|
+
</div>
|
|
744
|
+
</Section>
|
|
745
|
+
|
|
746
|
+
<Section
|
|
747
|
+
title={t('checkMultiVersion')}
|
|
748
|
+
count={report.multiVersion.length}
|
|
749
|
+
empty={t('checkMultiEmpty')}
|
|
750
|
+
overview={report.multiVersion.length > 0 ? `${report.multiVersion[0]?.name}: ${report.multiVersion[0]?.versions.join(' / ')}` : undefined}
|
|
751
|
+
>
|
|
752
|
+
<div className={css.diagList}>
|
|
753
|
+
{report.multiVersion.map(mv => (
|
|
754
|
+
<div key={mv.name} className={css.diagRow}>
|
|
755
|
+
<code className={css.diagVal}>{mv.name}</code>
|
|
756
|
+
<span className={css.spec}>{mv.versions.join(' / ')}</span>
|
|
757
|
+
{mv.hoisted !== null && <span className={css.spec}>{t('checkHoisted')}: {mv.hoisted}</span>}
|
|
758
|
+
</div>
|
|
759
|
+
))}
|
|
760
|
+
</div>
|
|
761
|
+
</Section>
|
|
762
|
+
|
|
763
|
+
<Section
|
|
764
|
+
title={t('checkOverrides')}
|
|
765
|
+
count={report.overrides.length}
|
|
766
|
+
empty={t('checkOverridesEmpty')}
|
|
767
|
+
overview={report.overrides.length > 0 ? `${report.overrides[0]?.id} ← ${report.overrides[0]?.layer}` : undefined}
|
|
768
|
+
>
|
|
769
|
+
<div className={css.diagList}>
|
|
770
|
+
{report.overrides.map((ov, i) => (
|
|
771
|
+
<div key={i} className={css.ovRow}>
|
|
772
|
+
<code className={css.diagVal}>{ov.id}</code>
|
|
773
|
+
<span className={css.ovArrow}>←</span>
|
|
774
|
+
<span className={css.ovByTag}>{ov.layer}</span>
|
|
775
|
+
<span className={css.spec}>{t('checkOverridden')}</span>
|
|
776
|
+
<span className={css.ovFrom}>{ov.overriddenLayers.join(', ')}</span>
|
|
777
|
+
</div>
|
|
778
|
+
))}
|
|
779
|
+
</div>
|
|
780
|
+
</Section>
|
|
781
|
+
|
|
782
|
+
<Section
|
|
783
|
+
title={t('checkOrphans')}
|
|
784
|
+
count={report.orphans.length}
|
|
785
|
+
empty={t('checkOrphansEmpty')}
|
|
786
|
+
overview={report.orphans.length > 0 ? `${report.orphans[0]?.id}(${t(orphanKindLabel(report.orphans[0]?.reason ?? ''))})` : undefined}
|
|
787
|
+
>
|
|
788
|
+
<div className={css.diagList}>
|
|
789
|
+
{report.orphans.map((orphan, i) => (
|
|
790
|
+
<div key={i} className={css.orphRow}>
|
|
791
|
+
<span className={css.orphBadge}>{t(orphanKindLabel(orphan.reason))}</span>
|
|
792
|
+
<code className={css.diagVal}>{orphan.id}</code>
|
|
793
|
+
<span className={css.nm}>{orphan.layer}</span>
|
|
794
|
+
<span className={css.spec}>{orphan.reason}</span>
|
|
795
|
+
</div>
|
|
796
|
+
))}
|
|
797
|
+
</div>
|
|
798
|
+
</Section>
|
|
799
|
+
|
|
800
|
+
{/* issue #98 phase 2 (step 1): community-bundle ordering */}
|
|
801
|
+
<CollapsibleSection title={t('orderSection')} count={order.length} open={orderOpen} onToggle={() => setOrderOpen(o => !o)}>
|
|
802
|
+
<p className={css.panelNote}>{t('orderDragHint')}</p>
|
|
803
|
+
{report.orderConflicts !== undefined && report.orderConflicts.length > 0 && (
|
|
804
|
+
<div className={css.diagList}>
|
|
805
|
+
<span className={css.diagKey}>{t('orderConflicts')}</span>
|
|
806
|
+
{report.orderConflicts.map((conflict, i) => (
|
|
807
|
+
<div key={i} className={css.warnLine}>{conflict.name} — {conflict.reason}</div>
|
|
808
|
+
))}
|
|
809
|
+
</div>
|
|
810
|
+
)}
|
|
811
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
812
|
+
<Button variant="primary" size="sm" disabled={order.length === 0 || orderBusy} onClick={() => applyOrder()}>
|
|
813
|
+
{orderBusy ? '…' : t('orderApply')}
|
|
814
|
+
</Button>
|
|
815
|
+
{suggested !== null && suggested.ok === true
|
|
816
|
+
&& suggested.order.join('\u0000') !== communityNames.join('\u0000')
|
|
817
|
+
&& (
|
|
818
|
+
<Button variant="outline" size="sm" disabled={orderBusy} onClick={() => applyOrder(suggested.order)}>
|
|
819
|
+
{t('orderSuggestApply')}
|
|
820
|
+
</Button>
|
|
821
|
+
)}
|
|
822
|
+
{suggested !== null && suggested.ok === true
|
|
823
|
+
&& suggested.order.join('\u0000') === communityNames.join('\u0000')
|
|
824
|
+
&& <span className={css.okState}>{t('orderAlreadyOptimal')}</span>}
|
|
825
|
+
{order.join('\u0000') !== communityNames.join('\u0000') && (
|
|
826
|
+
<Button variant="ghost" size="sm" disabled={orderBusy} onClick={() => setOrder(communityNames)}>
|
|
827
|
+
{t('orderReset')}
|
|
828
|
+
</Button>
|
|
829
|
+
)}
|
|
830
|
+
{orderMsg !== null && <span className={css.okState}>{orderMsg}</span>}
|
|
831
|
+
{orderErr !== null && <span className={css.err}>{orderErr}</span>}
|
|
832
|
+
</div>
|
|
833
|
+
{orderDiff !== null && (
|
|
834
|
+
<div className={css.panelNote}>
|
|
835
|
+
{t('orderDiffHint').replace('{0}', String(orderDiff.overrides)).replace('{1}', String(orderDiff.orphans)).replace('{2}', String(orderDiff.duplicates))}
|
|
836
|
+
</div>
|
|
837
|
+
)}
|
|
838
|
+
{suggested !== null && suggested.ok === false && (
|
|
839
|
+
<div className={css.warnLine}>{t('orderSuggestHint')} ⚠ {suggested.cycle.join(' → ')}</div>
|
|
840
|
+
)}
|
|
841
|
+
{report.duplicateNames !== undefined && report.duplicateNames.length > 0 && (
|
|
842
|
+
<div className={css.diagList}>
|
|
843
|
+
<span className={css.diagKey}>{t('duplicateNames')}</span>
|
|
844
|
+
{report.duplicateNames.map((dup, i) => (
|
|
845
|
+
<div key={i} className={css.panelNote}>{dup.name} × {dup.count} — {dup.layers.join(' / ')}</div>
|
|
846
|
+
))}
|
|
847
|
+
</div>
|
|
848
|
+
)}
|
|
849
|
+
{order.length === 0
|
|
850
|
+
? <div className={css.diagEmpty}>—</div>
|
|
851
|
+
: (
|
|
852
|
+
<div className={css.diagList}>
|
|
853
|
+
{order.map((name, i) => (
|
|
854
|
+
<div
|
|
855
|
+
key={name}
|
|
856
|
+
draggable={!orderBusy}
|
|
857
|
+
className={[
|
|
858
|
+
css.diagRow,
|
|
859
|
+
dragIndex === i ? css.dragging : '',
|
|
860
|
+
dragOverIndex === i ? css.dragOver : '',
|
|
861
|
+
].filter(Boolean).join(' ')}
|
|
862
|
+
onDragStart={onRowDragStart(i)}
|
|
863
|
+
onDragOver={onRowDragOver(i)}
|
|
864
|
+
onDragLeave={onRowDragLeave(i)}
|
|
865
|
+
onDrop={onRowDrop(i)}
|
|
866
|
+
onDragEnd={onRowDragEnd}
|
|
867
|
+
>
|
|
868
|
+
<span className={css.dragHandle} aria-label={t('orderDrag')} title={t('orderDrag')}>⠿</span>
|
|
869
|
+
<span className={css.diagIndex}>{i + 1}</span>
|
|
870
|
+
<span className={css.nm}>{name}</span>
|
|
871
|
+
<span className={css.grow} />
|
|
872
|
+
<Button
|
|
873
|
+
variant="ghost"
|
|
874
|
+
size="sm"
|
|
875
|
+
draggable={false}
|
|
876
|
+
aria-label={t('orderUp')}
|
|
877
|
+
disabled={i === 0 || orderBusy}
|
|
878
|
+
onClick={() => moveBundle(i, -1)}
|
|
879
|
+
>{t('orderUp')}</Button>
|
|
880
|
+
<Button
|
|
881
|
+
variant="ghost"
|
|
882
|
+
size="sm"
|
|
883
|
+
draggable={false}
|
|
884
|
+
aria-label={t('orderDown')}
|
|
885
|
+
disabled={i >= order.length - 1 || orderBusy}
|
|
886
|
+
onClick={() => moveBundle(i, 1)}
|
|
887
|
+
>{t('orderDown')}</Button>
|
|
888
|
+
</div>
|
|
889
|
+
))}
|
|
890
|
+
</div>
|
|
891
|
+
)}
|
|
892
|
+
</CollapsibleSection>
|
|
893
|
+
|
|
894
|
+
{/* issue #98 phase 3: snapshots & rollback */}
|
|
895
|
+
<CollapsibleSection title={t('presetSection')} open={presetOpen} onToggle={() => setPresetOpen(o => !o)}>
|
|
896
|
+
{/* The ordering DRAFT, not the last-reported order: a preset must
|
|
897
|
+
capture the arrangement on screen, or saving right after a reorder
|
|
898
|
+
stores the composition the user just moved away from. */}
|
|
899
|
+
<PresetPanel t={t} open={presetOpen} bundleOrder={order} onRefresh={refresh} />
|
|
900
|
+
</CollapsibleSection>
|
|
901
|
+
|
|
902
|
+
<CollapsibleSection title={t('snapSection')} open={snapOpen} onToggle={() => setSnapOpen(o => !o)}>
|
|
903
|
+
<SnapshotPanel t={t} open={snapOpen} onRefresh={refresh} />
|
|
904
|
+
</CollapsibleSection>
|
|
905
|
+
</div>
|
|
906
|
+
)
|
|
907
|
+
}
|