@cat-factory/app 0.49.1 → 0.50.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.
@@ -0,0 +1,383 @@
1
+ <script setup lang="ts">
2
+ // The Kubernetes "ephemeral environment backend" connect form — one option of the
3
+ // environment tab's backend-type selector (the other being the BYO HTTP manifest editor).
4
+ // It builds the discriminated `{ kind: 'kubernetes', kubernetes }` config plus the
5
+ // `apiToken` secret bundle and emits test/save to the parent tab (which calls the shared
6
+ // provider-connections store). The K8s env config differs from the runner K8s config
7
+ // (manifest source + URL derivation vs an executor image + namespace), so it has its own
8
+ // form rather than reusing KubernetesRunnerForm.
9
+ import { computed, reactive, ref, watch } from 'vue'
10
+ import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
+ import type { ProviderConnection } from '~/types/providerConnections'
12
+
13
+ const props = defineProps<{
14
+ connection: ProviderConnection | null
15
+ supportsTest: boolean
16
+ testing: boolean
17
+ busy: boolean
18
+ testResult: { ok: boolean; message?: string } | null
19
+ }>()
20
+
21
+ const emit = defineEmits<{
22
+ test: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
23
+ save: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
24
+ }>()
25
+
26
+ const { t } = useI18n()
27
+
28
+ const form = reactive({
29
+ label: '',
30
+ apiServerUrl: '',
31
+ caCertPem: '',
32
+ insecureSkipTlsVerify: false,
33
+ namespaceTemplate: '',
34
+ imageTemplate: '',
35
+ // manifest source
36
+ manifestSourceType: 'colocated' as 'colocated' | 'separate',
37
+ manifestPath: '',
38
+ manifestRepo: '',
39
+ manifestRef: '',
40
+ // url derivation
41
+ urlSource: 'ingressTemplate' as 'ingressTemplate' | 'ingressStatus' | 'serviceStatus',
42
+ hostTemplate: '',
43
+ ingressName: '',
44
+ serviceName: '',
45
+ servicePort: '',
46
+ urlScheme: '' as '' | 'http' | 'https',
47
+ })
48
+ const apiToken = ref('')
49
+
50
+ const manifestSourceItems = computed(() => [
51
+ { label: t('settings.providerConnection.kubernetesEnv.sourceColocated'), value: 'colocated' },
52
+ { label: t('settings.providerConnection.kubernetesEnv.sourceSeparate'), value: 'separate' },
53
+ ])
54
+ const urlSourceItems = computed(() => [
55
+ {
56
+ label: t('settings.providerConnection.kubernetesEnv.urlIngressTemplate'),
57
+ value: 'ingressTemplate',
58
+ },
59
+ {
60
+ label: t('settings.providerConnection.kubernetesEnv.urlIngressStatus'),
61
+ value: 'ingressStatus',
62
+ },
63
+ {
64
+ label: t('settings.providerConnection.kubernetesEnv.urlServiceStatus'),
65
+ value: 'serviceStatus',
66
+ },
67
+ ])
68
+ const schemeItems = computed(() => [
69
+ { label: t('settings.providerConnection.kubernetesEnv.schemeDefault'), value: '' },
70
+ { label: 'https', value: 'https' },
71
+ { label: 'http', value: 'http' },
72
+ ])
73
+
74
+ // A registered k8s env connection exposes its non-secret config, so prefill every
75
+ // non-secret field from it (never the token — secrets are write-only and re-entered on
76
+ // update). This lets an edit change one field without re-typing the whole form.
77
+ watch(
78
+ () => props.connection,
79
+ (c) => {
80
+ if (c?.kind !== 'kubernetes') return
81
+ const k =
82
+ c.config && (c.config as { kind?: string }).kind === 'kubernetes'
83
+ ? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
84
+ : undefined
85
+ if (!k) return
86
+ form.label = typeof k.label === 'string' ? k.label : ''
87
+ form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
88
+ form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
89
+ form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
90
+ form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
91
+ form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
92
+ const src = k.manifestSource as Record<string, unknown> | undefined
93
+ if (src?.type === 'separate') {
94
+ form.manifestSourceType = 'separate'
95
+ form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
96
+ form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
97
+ form.manifestPath = typeof src.path === 'string' ? src.path : ''
98
+ } else if (src?.type === 'colocated') {
99
+ form.manifestSourceType = 'colocated'
100
+ form.manifestPath = typeof src.path === 'string' ? src.path : ''
101
+ }
102
+ const url = k.url as Record<string, unknown> | undefined
103
+ if (url?.source === 'ingressTemplate') {
104
+ form.urlSource = 'ingressTemplate'
105
+ form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
106
+ } else if (url?.source === 'ingressStatus') {
107
+ form.urlSource = 'ingressStatus'
108
+ form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
109
+ } else if (url?.source === 'serviceStatus') {
110
+ form.urlSource = 'serviceStatus'
111
+ form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
112
+ form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
113
+ }
114
+ if (url && (url.scheme === 'http' || url.scheme === 'https')) form.urlScheme = url.scheme
115
+ },
116
+ { immediate: true },
117
+ )
118
+
119
+ // Mirror kubernetesManifestSourceSchema's `owner/repo` regex so a slashless value is
120
+ // caught here with a field hint instead of a generic 422 from the backend.
121
+ const repoShapeValid = computed(() => /^[^/\s]+\/[^/\s]+$/.test(form.manifestRepo.trim()))
122
+ const manifestSourceValid = computed(() =>
123
+ form.manifestSourceType === 'separate'
124
+ ? repoShapeValid.value && !!form.manifestPath.trim()
125
+ : !!form.manifestPath.trim(),
126
+ )
127
+ // serviceStatus.port is an optional integer 1..65535 (kubernetesUrlSourceSchema). Validate
128
+ // it here so a decimal isn't silently dropped and an out-of-range value isn't sent then 422'd.
129
+ const servicePortValid = computed(() => {
130
+ const raw = form.servicePort.trim()
131
+ if (!raw) return true
132
+ const port = Number(raw)
133
+ return Number.isInteger(port) && port >= 1 && port <= 65535
134
+ })
135
+ const urlValid = computed(() => {
136
+ if (form.urlSource === 'ingressTemplate') return !!form.hostTemplate.trim()
137
+ if (form.urlSource === 'serviceStatus') return !!form.serviceName.trim() && servicePortValid.value
138
+ return true // ingressStatus has no required field
139
+ })
140
+
141
+ const canSave = computed(
142
+ () =>
143
+ !!form.label.trim() &&
144
+ !!form.apiServerUrl.trim() &&
145
+ !!apiToken.value.trim() &&
146
+ manifestSourceValid.value &&
147
+ urlValid.value,
148
+ )
149
+
150
+ function buildManifestSource(): Record<string, unknown> {
151
+ if (form.manifestSourceType === 'separate') {
152
+ const src: Record<string, unknown> = {
153
+ type: 'separate',
154
+ repo: form.manifestRepo.trim(),
155
+ path: form.manifestPath.trim(),
156
+ }
157
+ if (form.manifestRef.trim()) src.ref = form.manifestRef.trim()
158
+ return src
159
+ }
160
+ return { type: 'colocated', path: form.manifestPath.trim() }
161
+ }
162
+
163
+ function buildUrl(): Record<string, unknown> {
164
+ const url: Record<string, unknown> = { source: form.urlSource }
165
+ if (form.urlSource === 'ingressTemplate') {
166
+ url.hostTemplate = form.hostTemplate.trim()
167
+ } else if (form.urlSource === 'ingressStatus') {
168
+ if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
169
+ } else {
170
+ url.serviceName = form.serviceName.trim()
171
+ const port = Number(form.servicePort)
172
+ if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
173
+ }
174
+ if (form.urlScheme) url.scheme = form.urlScheme
175
+ return url
176
+ }
177
+
178
+ function buildPayload(): { config: Record<string, unknown>; secrets: Record<string, string> } {
179
+ const kubernetes: Record<string, unknown> = {
180
+ label: form.label.trim(),
181
+ apiServerUrl: form.apiServerUrl.trim(),
182
+ manifestSource: buildManifestSource(),
183
+ url: buildUrl(),
184
+ }
185
+ if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
186
+ if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
187
+ if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
188
+ if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
189
+ return {
190
+ config: { kind: 'kubernetes', kubernetes },
191
+ secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: apiToken.value.trim() },
192
+ }
193
+ }
194
+
195
+ function optional(label: string): string {
196
+ return t('settings.providerConnection.form.optionalLabel', { label })
197
+ }
198
+ </script>
199
+
200
+ <template>
201
+ <div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
202
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
203
+ {{
204
+ connection?.kind === 'kubernetes'
205
+ ? t('settings.providerConnection.form.updateConfiguration')
206
+ : t('settings.providerConnection.form.connect')
207
+ }}
208
+ </p>
209
+
210
+ <UFormField :label="t('settings.providerConnection.kubernetesEnv.label')">
211
+ <UInput
212
+ v-model="form.label"
213
+ :placeholder="t('settings.providerConnection.kubernetesEnv.labelPlaceholder')"
214
+ />
215
+ </UFormField>
216
+
217
+ <UFormField
218
+ :label="t('settings.providerConnection.kubernetesEnv.apiServerUrl')"
219
+ :help="t('settings.providerConnection.kubernetesEnv.apiServerUrlHelp')"
220
+ >
221
+ <UInput v-model="form.apiServerUrl" class="font-mono" placeholder="https://10.0.0.1:6443" />
222
+ </UFormField>
223
+
224
+ <UFormField
225
+ :label="t('settings.providerConnection.kubernetesEnv.apiToken')"
226
+ :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
227
+ >
228
+ <UInput v-model="apiToken" type="password" class="font-mono" />
229
+ </UFormField>
230
+
231
+ <!-- Manifest source: where the per-PR resources are read from. -->
232
+ <UFormField :label="t('settings.providerConnection.kubernetesEnv.manifestSourceLabel')">
233
+ <USelect v-model="form.manifestSourceType" :items="manifestSourceItems" />
234
+ </UFormField>
235
+
236
+ <UFormField
237
+ v-if="form.manifestSourceType === 'separate'"
238
+ :label="t('settings.providerConnection.kubernetesEnv.repo')"
239
+ :help="t('settings.providerConnection.kubernetesEnv.repoHelp')"
240
+ >
241
+ <UInput v-model="form.manifestRepo" class="font-mono" placeholder="acme/preview-manifests" />
242
+ </UFormField>
243
+
244
+ <UFormField
245
+ v-if="form.manifestSourceType === 'separate'"
246
+ :label="optional(t('settings.providerConnection.kubernetesEnv.ref'))"
247
+ :help="t('settings.providerConnection.kubernetesEnv.refHelp')"
248
+ >
249
+ <UInput v-model="form.manifestRef" class="font-mono" placeholder="main" />
250
+ </UFormField>
251
+
252
+ <UFormField
253
+ :label="t('settings.providerConnection.kubernetesEnv.path')"
254
+ :help="t('settings.providerConnection.kubernetesEnv.pathHelp')"
255
+ >
256
+ <UInput v-model="form.manifestPath" class="font-mono" placeholder="k8s/preview" />
257
+ </UFormField>
258
+
259
+ <!-- URL derivation: how the live environment URL is resolved once applied. -->
260
+ <UFormField :label="t('settings.providerConnection.kubernetesEnv.urlSourceLabel')">
261
+ <USelect v-model="form.urlSource" :items="urlSourceItems" />
262
+ </UFormField>
263
+
264
+ <UFormField
265
+ v-if="form.urlSource === 'ingressTemplate'"
266
+ :label="t('settings.providerConnection.kubernetesEnv.hostTemplate')"
267
+ :help="t('settings.providerConnection.kubernetesEnv.hostTemplateHelp')"
268
+ >
269
+ <UInput
270
+ v-model="form.hostTemplate"
271
+ class="font-mono"
272
+ placeholder="{{branch}}.preview.example.com"
273
+ />
274
+ </UFormField>
275
+
276
+ <UFormField
277
+ v-if="form.urlSource === 'ingressStatus'"
278
+ :label="optional(t('settings.providerConnection.kubernetesEnv.ingressName'))"
279
+ :help="t('settings.providerConnection.kubernetesEnv.ingressNameHelp')"
280
+ >
281
+ <UInput v-model="form.ingressName" class="font-mono" />
282
+ </UFormField>
283
+
284
+ <UFormField
285
+ v-if="form.urlSource === 'serviceStatus'"
286
+ :label="t('settings.providerConnection.kubernetesEnv.serviceName')"
287
+ >
288
+ <UInput v-model="form.serviceName" class="font-mono" />
289
+ </UFormField>
290
+
291
+ <UFormField
292
+ v-if="form.urlSource === 'serviceStatus'"
293
+ :label="optional(t('settings.providerConnection.kubernetesEnv.port'))"
294
+ >
295
+ <UInput
296
+ v-model="form.servicePort"
297
+ type="number"
298
+ :min="1"
299
+ :max="65535"
300
+ class="font-mono"
301
+ placeholder="80"
302
+ />
303
+ </UFormField>
304
+
305
+ <UFormField :label="optional(t('settings.providerConnection.kubernetesEnv.scheme'))">
306
+ <USelect v-model="form.urlScheme" :items="schemeItems" />
307
+ </UFormField>
308
+
309
+ <!-- Optional refinements. -->
310
+ <UFormField
311
+ :label="optional(t('settings.providerConnection.kubernetesEnv.namespaceTemplate'))"
312
+ :help="t('settings.providerConnection.kubernetesEnv.namespaceTemplateHelp')"
313
+ >
314
+ <UInput
315
+ v-model="form.namespaceTemplate"
316
+ class="font-mono"
317
+ placeholder="cf-env-{{pullNumber}}"
318
+ />
319
+ </UFormField>
320
+
321
+ <UFormField
322
+ :label="optional(t('settings.providerConnection.kubernetesEnv.imageTemplate'))"
323
+ :help="t('settings.providerConnection.kubernetesEnv.imageTemplateHelp')"
324
+ >
325
+ <UInput v-model="form.imageTemplate" class="font-mono" />
326
+ </UFormField>
327
+
328
+ <UFormField
329
+ :label="optional(t('settings.providerConnection.kubernetesEnv.caCertPem'))"
330
+ :help="t('settings.providerConnection.kubernetesEnv.caCertPemHelp')"
331
+ >
332
+ <UTextarea
333
+ v-model="form.caCertPem"
334
+ :rows="3"
335
+ class="font-mono"
336
+ placeholder="-----BEGIN CERTIFICATE-----"
337
+ />
338
+ </UFormField>
339
+
340
+ <UFormField :help="t('settings.providerConnection.kubernetesEnv.insecureSkipTlsVerifyHelp')">
341
+ <UCheckbox
342
+ v-model="form.insecureSkipTlsVerify"
343
+ :label="t('settings.providerConnection.kubernetesEnv.insecureSkipTlsVerify')"
344
+ />
345
+ </UFormField>
346
+
347
+ <div v-if="supportsTest" class="flex items-center gap-2">
348
+ <UButton
349
+ color="neutral"
350
+ variant="soft"
351
+ size="sm"
352
+ icon="i-lucide-plug-zap"
353
+ :loading="testing"
354
+ :disabled="!canSave"
355
+ @click="emit('test', buildPayload())"
356
+ >
357
+ {{ t('settings.providerConnection.test.button') }}
358
+ </UButton>
359
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
360
+ {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
361
+ </span>
362
+ <span v-else-if="testResult" class="text-xs text-rose-400">
363
+ {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
364
+ </span>
365
+ </div>
366
+
367
+ <div class="flex justify-end">
368
+ <UButton
369
+ color="primary"
370
+ size="sm"
371
+ :loading="busy"
372
+ :disabled="!canSave"
373
+ @click="emit('save', buildPayload())"
374
+ >
375
+ {{
376
+ connection?.kind === 'kubernetes'
377
+ ? t('common.save')
378
+ : t('settings.providerConnection.form.connect')
379
+ }}
380
+ </UButton>
381
+ </div>
382
+ </div>
383
+ </template>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ // Local-mode-only: the warm-container pool + per-repo checkout reuse. These are a
3
+ // per-DEPLOYMENT singleton stored in the DB (they replaced the LOCAL_POOL_* / HARNESS_* env
4
+ // vars), so a developer tunes them here instead of editing .env. The warm pool keeps idle
5
+ // harness containers ready and re-leases one (preferring repo affinity) to each run — far
6
+ // faster startup than a cold container per run. Saving applies the new sizing to the running
7
+ // service immediately (live resize, no restart); in-flight runs keep the container they hold,
8
+ // and the checkout config applies to containers started after the save.
9
+ //
10
+ // Previously a standalone modal (LocalModeSettingsPanel); now folded into the Agent-containers
11
+ // tab of the Infrastructure window, since the warm pool IS the local agent-container runtime.
12
+ import { reactive, ref, watch } from 'vue'
13
+
14
+ const { t } = useI18n()
15
+ const store = useLocalSettingsStore()
16
+ const toast = useToast()
17
+
18
+ const saving = ref(false)
19
+
20
+ // Editable draft. `idleMinutes` and `cleanKeep` are friendlier renderings of the stored
21
+ // `pool.idleTtlMs` (ms) and `checkout.cleanKeep` (string[]).
22
+ const draft = reactive({
23
+ size: 0,
24
+ minWarm: 0,
25
+ max: null as number | null,
26
+ idleMinutes: 10,
27
+ workspaceRoot: '/workspace',
28
+ cleanKeep: 'node_modules,.venv,target,.gradle,.pnpm-store',
29
+ })
30
+
31
+ function syncDraft() {
32
+ const s = store.settings
33
+ if (!s) return
34
+ draft.size = s.pool.size
35
+ draft.minWarm = s.pool.minWarm
36
+ draft.max = s.pool.max
37
+ draft.idleMinutes = Math.round(s.pool.idleTtlMs / 60_000)
38
+ draft.workspaceRoot = s.checkout.workspaceRoot
39
+ draft.cleanKeep = s.checkout.cleanKeep.join(',')
40
+ }
41
+
42
+ // Load + hydrate the draft on mount (the tab only mounts in local mode).
43
+ void store.load().then(syncDraft)
44
+ watch(() => store.settings, syncDraft)
45
+
46
+ async function save() {
47
+ const cleanKeep = draft.cleanKeep
48
+ .split(',')
49
+ .map((s) => s.trim())
50
+ .filter(Boolean)
51
+ saving.value = true
52
+ try {
53
+ await store.save({
54
+ pool: {
55
+ size: Math.max(0, Math.floor(draft.size)),
56
+ minWarm: Math.max(0, Math.floor(draft.minWarm)),
57
+ max: draft.max == null ? null : Math.max(0, Math.floor(draft.max)),
58
+ idleTtlMs: Math.max(0, Math.floor(draft.idleMinutes * 60_000)),
59
+ },
60
+ checkout: { workspaceRoot: draft.workspaceRoot.trim() || '/workspace', cleanKeep },
61
+ })
62
+ toast.add({
63
+ title: t('settings.localMode.toast.saved'),
64
+ icon: 'i-lucide-check',
65
+ color: 'success',
66
+ })
67
+ } catch (e) {
68
+ toast.add({
69
+ title: t('settings.localMode.toast.saveFailed'),
70
+ description: e instanceof Error ? e.message : String(e),
71
+ icon: 'i-lucide-triangle-alert',
72
+ color: 'error',
73
+ })
74
+ } finally {
75
+ saving.value = false
76
+ }
77
+ }
78
+ </script>
79
+
80
+ <template>
81
+ <div class="space-y-6" data-testid="local-container-pool-settings">
82
+ <i18n-t
83
+ keypath="settings.localMode.intro"
84
+ tag="p"
85
+ class="text-xs text-slate-400"
86
+ scope="global"
87
+ >
88
+ <template #poolVars>
89
+ <code>LOCAL_POOL_*</code>
90
+ </template>
91
+ <template #harnessVars>
92
+ <code>HARNESS_*</code>
93
+ </template>
94
+ </i18n-t>
95
+
96
+ <!-- Warm container pool -->
97
+ <section class="space-y-3">
98
+ <div>
99
+ <h4 class="text-sm font-semibold text-slate-200">
100
+ {{ t('settings.localMode.pool.heading') }}
101
+ </h4>
102
+ <i18n-t
103
+ keypath="settings.localMode.pool.description"
104
+ tag="p"
105
+ class="text-[11px] text-slate-400"
106
+ scope="global"
107
+ >
108
+ <template #appleContainer>
109
+ <code>container</code>
110
+ </template>
111
+ </i18n-t>
112
+ </div>
113
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
114
+ <UFormField
115
+ :label="t('settings.localMode.pool.size.label')"
116
+ :help="t('settings.localMode.pool.size.help')"
117
+ >
118
+ <UInput v-model.number="draft.size" type="number" :min="0" size="sm" />
119
+ </UFormField>
120
+ <UFormField
121
+ :label="t('settings.localMode.pool.minWarm.label')"
122
+ :help="t('settings.localMode.pool.minWarm.help')"
123
+ >
124
+ <UInput v-model.number="draft.minWarm" type="number" :min="0" size="sm" />
125
+ </UFormField>
126
+ <UFormField
127
+ :label="t('settings.localMode.pool.max.label')"
128
+ :help="t('settings.localMode.pool.max.help')"
129
+ >
130
+ <UInput
131
+ v-model.number="draft.max"
132
+ type="number"
133
+ :min="0"
134
+ size="sm"
135
+ :placeholder="t('settings.localMode.pool.max.placeholder')"
136
+ />
137
+ </UFormField>
138
+ <UFormField
139
+ :label="t('settings.localMode.pool.idleTimeout.label')"
140
+ :help="t('settings.localMode.pool.idleTimeout.help')"
141
+ >
142
+ <UInput v-model.number="draft.idleMinutes" type="number" :min="0" size="sm" />
143
+ </UFormField>
144
+ </div>
145
+ </section>
146
+
147
+ <!-- Checkout reuse -->
148
+ <section class="space-y-3 border-t border-slate-800 pt-6">
149
+ <div>
150
+ <h4 class="text-sm font-semibold text-slate-200">
151
+ {{ t('settings.localMode.checkout.heading') }}
152
+ </h4>
153
+ <p class="text-[11px] text-slate-400">
154
+ {{ t('settings.localMode.checkout.description') }}
155
+ </p>
156
+ </div>
157
+ <UFormField
158
+ :label="t('settings.localMode.checkout.workspaceRoot.label')"
159
+ :help="t('settings.localMode.checkout.workspaceRoot.help')"
160
+ >
161
+ <UInput v-model="draft.workspaceRoot" size="sm" placeholder="/workspace" />
162
+ </UFormField>
163
+ <UFormField
164
+ :label="t('settings.localMode.checkout.cleanKeep.label')"
165
+ :help="t('settings.localMode.checkout.cleanKeep.help')"
166
+ >
167
+ <UInput
168
+ v-model="draft.cleanKeep"
169
+ size="sm"
170
+ placeholder="node_modules,.venv,target,.gradle,.pnpm-store"
171
+ />
172
+ </UFormField>
173
+ </section>
174
+
175
+ <div class="flex justify-end">
176
+ <UButton color="primary" icon="i-lucide-save" :loading="saving" @click="save">
177
+ {{ t('common.save') }}
178
+ </UButton>
179
+ </div>
180
+ </div>
181
+ </template>