@cat-factory/app 0.174.0 → 0.178.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 +52 -13
- package/app/components/board/nodes/BlockNode.vue +30 -0
- package/app/components/environments/steps/EnvPickStep.vue +3 -0
- package/app/components/layout/IntegrationBackTitle.vue +11 -8
- package/app/components/layout/IntegrationsHub.vue +35 -56
- package/app/components/layout/ModelProvidersHub.vue +250 -0
- package/app/components/panels/InspectorPanel.vue +3 -28
- package/app/components/panels/inspector/ContainerSummary.vue +54 -62
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +11 -0
- package/app/components/panels/inspector/ServiceValidationConfig.vue +99 -1
- package/app/components/settings/ComposeEnvironmentSetupSection.vue +102 -0
- package/app/components/settings/InfrastructureWindow.vue +11 -1
- package/app/composables/api/validationChecks.ts +8 -0
- package/app/composables/useIntegrationBack.ts +5 -2
- package/app/composables/useNavContributions.ts +1 -1
- package/app/modular/nav-contributions.spec.ts +64 -21
- package/app/modular/nav-contributions.ts +59 -26
- package/app/pages/index.vue +4 -0
- package/app/stores/ui/modals.ts +32 -6
- package/app/stores/validationChecks.ts +17 -2
- package/app/types/validationChecks.ts +3 -0
- package/app/utils/validationDetection.spec.ts +51 -0
- package/app/utils/validationDetection.ts +67 -0
- package/i18n/locales/de.json +110 -33
- package/i18n/locales/en.json +110 -33
- package/i18n/locales/es.json +110 -33
- package/i18n/locales/fr.json +110 -33
- package/i18n/locales/he.json +110 -33
- package/i18n/locales/it.json +110 -33
- package/i18n/locales/ja.json +110 -33
- package/i18n/locales/pl.json +110 -33
- package/i18n/locales/tr.json +110 -33
- package/i18n/locales/uk.json +110 -33
- package/package.json +2 -2
|
@@ -25,10 +25,21 @@ export type { AppSlots } from './slots'
|
|
|
25
25
|
/** Which shell(s) render a contribution. */
|
|
26
26
|
export type NavSurface = 'sidebar' | 'command' | 'toolbar'
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Sidebar section a contribution lands in (its i18n header is `nav.<group>`).
|
|
30
|
+
*
|
|
31
|
+
* `models` and `integrations` are deliberately SEPARATE sections even though a model
|
|
32
|
+
* provider is technically also an external system we connect to. They answer different
|
|
33
|
+
* questions: `models` is the ENGINE the harnesses run on (no provider ⇒ nothing runs at
|
|
34
|
+
* all), `integrations` is the optional systems that feed a run context or receive its
|
|
35
|
+
* output (source control, trackers, documents, chat, observability) — each of which a
|
|
36
|
+
* deployment can live without. Folding the providers in among them buried the one
|
|
37
|
+
* connection every deployment must make in a list of ones most never touch.
|
|
38
|
+
*/
|
|
29
39
|
export type NavSidebarGroup =
|
|
30
40
|
| 'create'
|
|
31
41
|
| 'repositories'
|
|
42
|
+
| 'models'
|
|
32
43
|
| 'integrations'
|
|
33
44
|
| 'infrastructure'
|
|
34
45
|
| 'workspaceContext'
|
|
@@ -90,10 +101,10 @@ export const NAV_ACTIONS = [
|
|
|
90
101
|
'addFromRepo',
|
|
91
102
|
'bootstrapRepo',
|
|
92
103
|
'integrationsHub',
|
|
104
|
+
'modelProviders',
|
|
93
105
|
'sandbox',
|
|
94
106
|
'kaizen',
|
|
95
107
|
'infrastructure',
|
|
96
|
-
'environmentSetup',
|
|
97
108
|
'fragmentLibrary',
|
|
98
109
|
'mergeThresholds',
|
|
99
110
|
'workspaceSettings',
|
|
@@ -153,24 +164,36 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
153
164
|
* with accounts enabled).
|
|
154
165
|
* - one icon per destination across shells.
|
|
155
166
|
*
|
|
156
|
-
* `advanced: true` marks the power-user half, hidden in basic interface mode.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
167
|
+
* `advanced: true` marks the power-user half, hidden in basic interface mode. Basic is the
|
|
168
|
+
* EVERYDAY DELIVERY SURFACE — plan work on a board, run it, review and merge it — so an item
|
|
169
|
+
* is advanced when it is not part of that loop. That happens two ways, and the difference
|
|
170
|
+
* matters when reading the list (it is the column `nav-contributions.spec.ts` makes you fill
|
|
171
|
+
* in):
|
|
161
172
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
173
|
+
* REACHED ANOTHER WAY — hiding a shortcut removes nothing, because a basic destination opens
|
|
174
|
+
* the same surface:
|
|
164
175
|
* - `merge-thresholds` / `service-fragment-defaults` — palette shortcuts into Workspace
|
|
165
176
|
* settings tabs (Merge, Service best practices), which basic mode reaches via
|
|
166
177
|
* `workspace-settings`.
|
|
167
|
-
* - `local-models` — a per-user endpoint knob the
|
|
178
|
+
* - `local-models` — a per-user endpoint knob the Model providers hub already offers.
|
|
168
179
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
180
|
+
* OUT OF THE TIER — the sole route, hidden deliberately, so the capability itself is absent
|
|
181
|
+
* from basic mode. This is a product decision, not an oversight: each of these answers a
|
|
182
|
+
* question an everyday delivery user does not ask, and carrying it costs the basic nav more
|
|
183
|
+
* than the capability is worth there. Switching to advanced is the way to it.
|
|
184
|
+
* - `sandbox` / `kaizen` — experimentation and self-grading surfaces, beside the delivery
|
|
185
|
+
* path rather than on it.
|
|
186
|
+
* - `bootstrap-repo` — standing a NEW repository up from a reference architecture: a
|
|
187
|
+
* once-per-service setup act, not part of running work on an existing board.
|
|
188
|
+
* - `operator-dashboard` / `reports` — the deployment-wide health and spend rollups. They
|
|
189
|
+
* read across every workspace for whoever operates the deployment, which is a different
|
|
190
|
+
* job from delivering a task on one board.
|
|
191
|
+
*
|
|
192
|
+
* Everything else stays in basic because the delivery loop needs it: authoring a flow
|
|
193
|
+
* (`build-pipeline`), adding a repo (`add-from-repo`), the standards/skills library
|
|
194
|
+
* (`fragments`), the PREnv + runner plumbing (`infrastructure`, which is also the only route
|
|
195
|
+
* to the guided per-service Compose environment setup), and the workspace/model configuration
|
|
196
|
+
* a run actually reads (`workspace-settings`, `model-config`).
|
|
174
197
|
*/
|
|
175
198
|
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
176
199
|
{
|
|
@@ -210,6 +233,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
210
233
|
labelKey: 'nav.bootstrapRepo',
|
|
211
234
|
icon: 'i-lucide-git-branch-plus',
|
|
212
235
|
surfaces: S('sidebar', 'command'),
|
|
236
|
+
advanced: true,
|
|
213
237
|
gate: (g) => g.canManageIntegrations,
|
|
214
238
|
action: 'bootstrapRepo',
|
|
215
239
|
testId: 'nav-bootstrap-repo',
|
|
@@ -221,6 +245,19 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
221
245
|
keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
|
|
222
246
|
},
|
|
223
247
|
},
|
|
248
|
+
{
|
|
249
|
+
// The engines. Kept out of `integrations-hub` on purpose (see NavSidebarGroup): a
|
|
250
|
+
// deployment with no provider connected cannot run anything, so this is the one
|
|
251
|
+
// connection that must not sit in a list of optional ones.
|
|
252
|
+
id: 'model-providers',
|
|
253
|
+
labelKey: 'nav.modelProviders',
|
|
254
|
+
icon: 'i-lucide-plug-zap',
|
|
255
|
+
surfaces: S('sidebar'),
|
|
256
|
+
gate: (g) => g.canManageIntegrations,
|
|
257
|
+
action: 'modelProviders',
|
|
258
|
+
testId: 'nav-model-providers',
|
|
259
|
+
sidebar: { group: 'models', order: 10 },
|
|
260
|
+
},
|
|
224
261
|
{
|
|
225
262
|
id: 'integrations-hub',
|
|
226
263
|
labelKey: 'nav.integrations',
|
|
@@ -268,16 +305,6 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
268
305
|
testId: 'nav-infrastructure',
|
|
269
306
|
sidebar: { group: 'infrastructure', order: 10 },
|
|
270
307
|
},
|
|
271
|
-
{
|
|
272
|
-
id: 'environment-setup',
|
|
273
|
-
labelKey: 'nav.environmentSetup',
|
|
274
|
-
icon: 'i-lucide-flask-conical',
|
|
275
|
-
surfaces: S('sidebar'),
|
|
276
|
-
gate: (g) => g.infrastructureAvailable,
|
|
277
|
-
action: 'environmentSetup',
|
|
278
|
-
testId: 'nav-environment-setup',
|
|
279
|
-
sidebar: { group: 'infrastructure', order: 20 },
|
|
280
|
-
},
|
|
281
308
|
{
|
|
282
309
|
id: 'fragments',
|
|
283
310
|
labelKey: 'nav.contextFragments',
|
|
@@ -332,7 +359,10 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
332
359
|
gate: (g) => g.canManageSettings,
|
|
333
360
|
action: 'modelConfiguration',
|
|
334
361
|
testId: 'nav-model-config',
|
|
335
|
-
|
|
362
|
+
// Beside `model-providers`, not in `configuration`: "which key do we hold" and "which
|
|
363
|
+
// model does each agent kind use" are two halves of one question, and splitting them
|
|
364
|
+
// across sections is what sent people to Integrations looking for a model.
|
|
365
|
+
sidebar: { group: 'models', order: 20 },
|
|
336
366
|
command: {
|
|
337
367
|
group: 'workspace',
|
|
338
368
|
order: 40,
|
|
@@ -388,6 +418,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
388
418
|
labelKey: 'nav.operatorDashboard',
|
|
389
419
|
icon: 'i-lucide-gauge',
|
|
390
420
|
surfaces: S('sidebar'),
|
|
421
|
+
advanced: true,
|
|
391
422
|
gate: (g) => g.accountsEnabled && g.isAccountAdmin,
|
|
392
423
|
action: 'operatorDashboard',
|
|
393
424
|
testId: 'nav-operator-dashboard',
|
|
@@ -398,6 +429,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
398
429
|
labelKey: 'nav.reports',
|
|
399
430
|
icon: 'i-lucide-chart-column',
|
|
400
431
|
surfaces: S('sidebar'),
|
|
432
|
+
advanced: true,
|
|
401
433
|
gate: (g) => g.accountsEnabled && g.isAccountAdmin,
|
|
402
434
|
action: 'reports',
|
|
403
435
|
testId: 'nav-reports',
|
|
@@ -477,6 +509,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
477
509
|
export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
|
|
478
510
|
'create',
|
|
479
511
|
'repositories',
|
|
512
|
+
'models',
|
|
480
513
|
'integrations',
|
|
481
514
|
'infrastructure',
|
|
482
515
|
'workspaceContext',
|
package/app/pages/index.vue
CHANGED
|
@@ -89,6 +89,9 @@ const ModelPresetHealthModal = defineAsyncComponent(
|
|
|
89
89
|
const IntegrationsHub = defineAsyncComponent(
|
|
90
90
|
() => import('~/components/layout/IntegrationsHub.vue'),
|
|
91
91
|
)
|
|
92
|
+
const ModelProvidersHub = defineAsyncComponent(
|
|
93
|
+
() => import('~/components/layout/ModelProvidersHub.vue'),
|
|
94
|
+
)
|
|
92
95
|
const PersonalSetupModal = defineAsyncComponent(
|
|
93
96
|
() => import('~/components/layout/PersonalSetupModal.vue'),
|
|
94
97
|
)
|
|
@@ -429,6 +432,7 @@ watch(
|
|
|
429
432
|
<RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
|
|
430
433
|
<ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
|
|
431
434
|
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
435
|
+
<ModelProvidersHub v-if="ui.modelProvidersOpen" />
|
|
432
436
|
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
433
437
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
434
438
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -952,20 +952,28 @@ function createConsumerOverlayHost() {
|
|
|
952
952
|
* the shared hub came-from markers.
|
|
953
953
|
*/
|
|
954
954
|
export function createUiModals() {
|
|
955
|
-
// Integrations / My-setup hub came-from markers — the one piece of state
|
|
956
|
-
// (many `open*` handlers reset it), so it lives here and `resetHubReturn`
|
|
957
|
-
// slices that need it. `cameFromIntegrations` is true while an
|
|
958
|
-
// AND it was reached from the Integrations hub;
|
|
955
|
+
// Model-providers / Integrations / My-setup hub came-from markers — the one piece of state
|
|
956
|
+
// SHARED across slices (many `open*` handlers reset it), so it lives here and `resetHubReturn`
|
|
957
|
+
// is threaded into the slices that need it. `cameFromIntegrations` is true while an
|
|
958
|
+
// integration's own panel is showing AND it was reached from the Integrations hub;
|
|
959
|
+
// `cameFromModelProviders` and `cameFromPersonal` are the analogues for the other two hubs.
|
|
960
|
+
//
|
|
961
|
+
// Three hubs, not two, because the vendor-credentials / OpenRouter / local-runner panels are
|
|
962
|
+
// reachable from more than one of them and a Back control that lands somewhere the user was
|
|
963
|
+
// never at is worse than none.
|
|
959
964
|
const cameFromIntegrations = ref(false)
|
|
965
|
+
const cameFromModelProviders = ref(false)
|
|
960
966
|
const cameFromPersonal = ref(false)
|
|
961
967
|
const integrationsOpen = ref(false)
|
|
968
|
+
const modelProvidersOpen = ref(false)
|
|
962
969
|
const personalSetupOpen = ref(false)
|
|
963
970
|
|
|
964
|
-
// Clear
|
|
971
|
+
// Clear EVERY hub came-from marker. Every direct `open*` in the slices calls this so that a
|
|
965
972
|
// panel opened outside the hubs never grows a dead Back control, and so switching from one
|
|
966
|
-
// hub's panel to
|
|
973
|
+
// hub's panel to another's clears the stale marker.
|
|
967
974
|
function resetHubReturn() {
|
|
968
975
|
cameFromIntegrations.value = false
|
|
976
|
+
cameFromModelProviders.value = false
|
|
969
977
|
cameFromPersonal.value = false
|
|
970
978
|
}
|
|
971
979
|
function openIntegrations() {
|
|
@@ -977,6 +985,13 @@ export function createUiModals() {
|
|
|
977
985
|
function closeIntegrations() {
|
|
978
986
|
integrationsOpen.value = false
|
|
979
987
|
}
|
|
988
|
+
function openModelProviders() {
|
|
989
|
+
resetHubReturn()
|
|
990
|
+
modelProvidersOpen.value = true
|
|
991
|
+
}
|
|
992
|
+
function closeModelProviders() {
|
|
993
|
+
modelProvidersOpen.value = false
|
|
994
|
+
}
|
|
980
995
|
function openPersonalSetup() {
|
|
981
996
|
resetHubReturn()
|
|
982
997
|
personalSetupOpen.value = true
|
|
@@ -1000,6 +1015,12 @@ export function createUiModals() {
|
|
|
1000
1015
|
cameFromIntegrations.value = true
|
|
1001
1016
|
integrationsOpen.value = false
|
|
1002
1017
|
}
|
|
1018
|
+
// The Model-providers analogue of `openFromIntegrations`.
|
|
1019
|
+
function openFromModelProviders(open: () => void) {
|
|
1020
|
+
open()
|
|
1021
|
+
cameFromModelProviders.value = true
|
|
1022
|
+
modelProvidersOpen.value = false
|
|
1023
|
+
}
|
|
1003
1024
|
|
|
1004
1025
|
const health = createHealthAdvisoryModals()
|
|
1005
1026
|
const misc = createMiscModals()
|
|
@@ -1014,11 +1035,16 @@ export function createUiModals() {
|
|
|
1014
1035
|
return {
|
|
1015
1036
|
integrationsOpen,
|
|
1016
1037
|
cameFromIntegrations,
|
|
1038
|
+
modelProvidersOpen,
|
|
1039
|
+
cameFromModelProviders,
|
|
1017
1040
|
personalSetupOpen,
|
|
1018
1041
|
cameFromPersonal,
|
|
1019
1042
|
openIntegrations,
|
|
1020
1043
|
closeIntegrations,
|
|
1021
1044
|
openFromIntegrations,
|
|
1045
|
+
openModelProviders,
|
|
1046
|
+
closeModelProviders,
|
|
1047
|
+
openFromModelProviders,
|
|
1022
1048
|
openPersonalSetup,
|
|
1023
1049
|
closePersonalSetup,
|
|
1024
1050
|
openFromPersonal,
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DetectedValidationChecks,
|
|
5
|
+
ServiceValidationConfig,
|
|
6
|
+
ValidationCheck,
|
|
7
|
+
} from '~/types/validationChecks'
|
|
4
8
|
import { VALIDATION_DEFAULT_MAX_ATTEMPTS } from '~/types/validationChecks'
|
|
5
9
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
6
10
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
@@ -107,5 +111,16 @@ export const useValidationChecksStore = defineStore('validationChecks', () => {
|
|
|
107
111
|
dropLocal(blockId)
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Ask the backend what this service's repo implies. Nothing is stored: the suggestion goes
|
|
116
|
+
* into the panel's unsaved rows, so the operator reviews and saves it like anything they
|
|
117
|
+
* typed. Deliberately NOT folded into `configs` — a suggestion that looked like saved
|
|
118
|
+
* config would report a service as configured when no run would run a single command.
|
|
119
|
+
*/
|
|
120
|
+
async function detect(blockId: string): Promise<DetectedValidationChecks> {
|
|
121
|
+
const ws = useWorkspaceStore()
|
|
122
|
+
return api.detectServiceValidationChecks(ws.requireId(), blockId)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { configs, loading, available, load, ensureLoaded, forBlock, save, remove, detect }
|
|
111
126
|
})
|
|
@@ -9,6 +9,9 @@ export type {
|
|
|
9
9
|
ValidationReport,
|
|
10
10
|
ServiceValidationConfig,
|
|
11
11
|
UpsertServiceValidationConfigInput,
|
|
12
|
+
DetectedValidationChecks,
|
|
13
|
+
ValidationDetectionStatus,
|
|
14
|
+
ValidationEcosystem,
|
|
12
15
|
} from '@cat-factory/contracts'
|
|
13
16
|
export {
|
|
14
17
|
VALIDATION_DEFAULT_MAX_ATTEMPTS,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mergeDetectedChecks } from './validationDetection'
|
|
3
|
+
|
|
4
|
+
const check = (label: string, command: string) => ({ label, command })
|
|
5
|
+
|
|
6
|
+
describe('mergeDetectedChecks', () => {
|
|
7
|
+
it('appends suggestions to an empty (or blank-row) panel', () => {
|
|
8
|
+
const result = mergeDetectedChecks(
|
|
9
|
+
[check('', '')],
|
|
10
|
+
[check('install', 'pnpm install --frozen-lockfile'), check('lint', 'pnpm run lint')],
|
|
11
|
+
10,
|
|
12
|
+
)
|
|
13
|
+
expect(result.rows).toEqual([
|
|
14
|
+
check('install', 'pnpm install --frozen-lockfile'),
|
|
15
|
+
check('lint', 'pnpm run lint'),
|
|
16
|
+
])
|
|
17
|
+
expect(result.added).toBe(2)
|
|
18
|
+
expect(result.dropped).toBe(0)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('never rewrites or duplicates what the operator already has', () => {
|
|
22
|
+
// The same command with a hand-tuned label must survive untouched, and pressing Detect
|
|
23
|
+
// again must be a no-op rather than a second copy of every check.
|
|
24
|
+
const existing = [check('our lint', 'pnpm run lint')]
|
|
25
|
+
const result = mergeDetectedChecks(existing, [check('lint', 'pnpm run lint')], 10)
|
|
26
|
+
expect(result.rows).toEqual(existing)
|
|
27
|
+
expect(result.added).toBe(0)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('disambiguates a label that collides with an existing row', () => {
|
|
31
|
+
// A duplicate label is REJECTED by the write contract, so a collision the merge created
|
|
32
|
+
// would surface as a save error on a row the operator never typed.
|
|
33
|
+
const result = mergeDetectedChecks(
|
|
34
|
+
[check('test', 'make test')],
|
|
35
|
+
[check('test', 'go test ./...')],
|
|
36
|
+
10,
|
|
37
|
+
)
|
|
38
|
+
expect(result.rows.map((r) => r.label)).toEqual(['test', 'test 2'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('reports what the row cap dropped instead of silently truncating', () => {
|
|
42
|
+
const result = mergeDetectedChecks(
|
|
43
|
+
[check('lint', 'pnpm run lint')],
|
|
44
|
+
[check('test', 'pnpm run test'), check('build', 'pnpm run build')],
|
|
45
|
+
2,
|
|
46
|
+
)
|
|
47
|
+
expect(result.rows).toHaveLength(2)
|
|
48
|
+
expect(result.added).toBe(1)
|
|
49
|
+
expect(result.dropped).toBe(1)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ValidationCheck } from '~/types/validationChecks'
|
|
2
|
+
|
|
3
|
+
// Folding an autodetected suggestion into the pre-PR validation panel's UNSAVED rows.
|
|
4
|
+
//
|
|
5
|
+
// Pure, and separate from the panel, because the merge carries the two rules that make the
|
|
6
|
+
// button safe to press: it never destroys what the operator already typed, and it never
|
|
7
|
+
// produces a row set the write contract would reject.
|
|
8
|
+
|
|
9
|
+
/** What a merge did, so the panel can say something specific rather than "done". */
|
|
10
|
+
export interface MergedDetection {
|
|
11
|
+
rows: ValidationCheck[]
|
|
12
|
+
/** Suggestions appended as new rows. */
|
|
13
|
+
added: number
|
|
14
|
+
/** Suggestions dropped because the row cap was already reached. */
|
|
15
|
+
dropped: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Append detected checks to the rows already on screen.
|
|
20
|
+
*
|
|
21
|
+
* - A suggestion whose COMMAND is already present is skipped, so pressing Detect twice (or
|
|
22
|
+
* pressing it on a service that is already configured) adds nothing rather than
|
|
23
|
+
* duplicating every check.
|
|
24
|
+
* - Existing rows are never rewritten. The button is assistive; silently replacing an
|
|
25
|
+
* operator's hand-tuned command with the generic guess would be a worse failure than not
|
|
26
|
+
* detecting anything.
|
|
27
|
+
* - Wholly blank rows (an unused "Add check" click) are dropped first, so the suggestion
|
|
28
|
+
* lands in a contiguous list instead of after a gap.
|
|
29
|
+
* - Labels are made unique against the surviving rows, because the write contract REJECTS a
|
|
30
|
+
* duplicate label — a merge that produced one would surface as an unexplained save error
|
|
31
|
+
* on rows the operator did not write.
|
|
32
|
+
*/
|
|
33
|
+
export function mergeDetectedChecks(
|
|
34
|
+
existing: ValidationCheck[],
|
|
35
|
+
detected: ValidationCheck[],
|
|
36
|
+
maxRows: number,
|
|
37
|
+
): MergedDetection {
|
|
38
|
+
const rows = existing.filter((r) => r.label.trim() !== '' || r.command.trim() !== '')
|
|
39
|
+
const commands = new Set(rows.map((r) => r.command.trim()))
|
|
40
|
+
const labels = new Set(rows.map((r) => r.label.trim()).filter((l) => l !== ''))
|
|
41
|
+
|
|
42
|
+
let added = 0
|
|
43
|
+
let dropped = 0
|
|
44
|
+
for (const candidate of detected) {
|
|
45
|
+
const command = candidate.command.trim()
|
|
46
|
+
if (command === '' || commands.has(command)) continue
|
|
47
|
+
if (rows.length >= maxRows) {
|
|
48
|
+
dropped += 1
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
const label = uniqueLabel(candidate.label.trim() || command, labels)
|
|
52
|
+
rows.push({ label, command })
|
|
53
|
+
commands.add(command)
|
|
54
|
+
labels.add(label)
|
|
55
|
+
added += 1
|
|
56
|
+
}
|
|
57
|
+
return { rows, added, dropped }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `lint` → `lint 2` → `lint 3` … until it no longer collides. Clamped to the contract's 80. */
|
|
61
|
+
function uniqueLabel(base: string, taken: Set<string>): string {
|
|
62
|
+
if (!taken.has(base)) return base.slice(0, 80)
|
|
63
|
+
for (let n = 2; ; n += 1) {
|
|
64
|
+
const candidate = `${base} ${n}`.slice(0, 80)
|
|
65
|
+
if (!taken.has(candidate)) return candidate
|
|
66
|
+
}
|
|
67
|
+
}
|