@cat-factory/app 0.293.0 → 0.294.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/app/components/board/AddTaskModal.vue +80 -0
- package/app/components/board/RecurringPipelineModal.vue +34 -20
- package/app/components/bootstrap/BootstrapModal.logic.spec.ts +43 -0
- package/app/components/bootstrap/BootstrapModal.logic.ts +28 -0
- package/app/components/bootstrap/BootstrapModal.vue +82 -15
- package/app/components/bugFishing/BugFishingWindow.vue +524 -0
- package/app/components/focus/BlockFocusView.vue +2 -0
- package/app/components/github/RepoTreeBrowser.vue +89 -6
- package/app/components/layout/NotificationsInbox.vue +15 -0
- package/app/components/panels/ResultWindowDrafts.logic.spec.ts +25 -3
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +46 -1
- package/app/components/slack/SlackPanel.vue +1 -0
- package/app/composables/api/bugFishing.ts +52 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +21 -0
- package/app/modular/result-views.ts +6 -0
- package/app/stores/bugFishing.ts +143 -0
- package/app/stores/ui/resultViews.ts +14 -7
- package/app/stores/ui/runStepOpeners.ts +29 -1
- package/app/stores/workspaceSettings.ts +1 -0
- package/app/types/execution.ts +9 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +25 -0
- package/app/utils/repoPath.spec.ts +49 -0
- package/app/utils/repoPath.ts +28 -0
- package/i18n/locales/de.json +103 -6
- package/i18n/locales/en.json +102 -5
- package/i18n/locales/es.json +103 -6
- package/i18n/locales/fr.json +103 -6
- package/i18n/locales/he.json +103 -6
- package/i18n/locales/it.json +103 -6
- package/i18n/locales/ja.json +103 -6
- package/i18n/locales/pl.json +103 -6
- package/i18n/locales/tr.json +103 -6
- package/i18n/locales/uk.json +103 -6
- package/package.json +2 -2
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
TaskTypeFields,
|
|
23
23
|
} from '~/types/domain'
|
|
24
24
|
import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
|
|
25
|
+
import { BUG_FISHING_PHASES } from '@cat-factory/contracts'
|
|
25
26
|
import { resolveComponentRegistry } from '@modular-vue/core'
|
|
26
27
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
27
28
|
import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
@@ -97,6 +98,11 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
|
|
|
97
98
|
const all: { value: TaskTypeChoice; label: string; icon: string }[] = [
|
|
98
99
|
{ value: 'feature', label: t('board.addTask.types.feature'), icon: 'i-lucide-sparkles' },
|
|
99
100
|
{ value: 'bug', label: t('board.addTask.types.bug'), icon: 'i-lucide-bug' },
|
|
101
|
+
{
|
|
102
|
+
value: 'bug-fishing',
|
|
103
|
+
label: t('board.addTask.types.bugFishing'),
|
|
104
|
+
icon: 'i-lucide-fish',
|
|
105
|
+
},
|
|
100
106
|
{ value: 'document', label: t('board.addTask.types.document'), icon: 'i-lucide-file-text' },
|
|
101
107
|
{ value: 'spike', label: t('board.addTask.types.spike'), icon: 'i-lucide-flask-conical' },
|
|
102
108
|
{
|
|
@@ -127,6 +133,11 @@ const isRecurring = computed(() => taskType.value === 'recurring')
|
|
|
127
133
|
const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
|
|
128
134
|
const stepsToReproduce = ref('')
|
|
129
135
|
const timeboxHours = ref<number | undefined>(undefined)
|
|
136
|
+
// Bug-fishing expedition: which ANGLES to fish (empty ⇒ every shipped angle, the intended
|
|
137
|
+
// default — an expedition exists to cover ground nobody thought to look at, so narrowing it is
|
|
138
|
+
// the deliberate act) plus an optional focus folded into every angle's prompt.
|
|
139
|
+
const fishingPhaseIds = ref<string[]>([])
|
|
140
|
+
const fishingFocus = ref('')
|
|
130
141
|
// Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
|
|
131
142
|
const spikeResearchQuestion = ref('')
|
|
132
143
|
const spikeSuccessCriteria = ref('')
|
|
@@ -284,6 +295,24 @@ function buildCustomTypeFields(): TaskTypeFields | undefined {
|
|
|
284
295
|
return Object.keys(bag).length ? { custom: bag } : undefined
|
|
285
296
|
}
|
|
286
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The bug-fishing expedition's creation fields. Both NARROW a hunt that otherwise covers every
|
|
300
|
+
* angle, so both are omitted when they narrow nothing: an empty selection and "every angle" are
|
|
301
|
+
* the same run, and storing the full catalog would freeze today's angle list onto a task that
|
|
302
|
+
* runs next quarter.
|
|
303
|
+
*
|
|
304
|
+
* Its own function rather than another arm of {@link buildTypeFields}, whose per-type chain is at
|
|
305
|
+
* its complexity ceiling — a budget is a split trigger, not a number to raise.
|
|
306
|
+
*/
|
|
307
|
+
function buildBugFishingFields(): TaskTypeFields | undefined {
|
|
308
|
+
const f: TaskTypeFields = {}
|
|
309
|
+
if (fishingPhaseIds.value.length && fishingPhaseIds.value.length < BUG_FISHING_PHASES.length) {
|
|
310
|
+
f.fishingPhaseIds = [...fishingPhaseIds.value]
|
|
311
|
+
}
|
|
312
|
+
if (fishingFocus.value.trim()) f.fishingFocus = fishingFocus.value.trim()
|
|
313
|
+
return Object.keys(f).length ? f : undefined
|
|
314
|
+
}
|
|
315
|
+
|
|
287
316
|
function buildTypeFields(): TaskTypeFields | undefined {
|
|
288
317
|
if (taskType.value === 'bug') {
|
|
289
318
|
const f: TaskTypeFields = {}
|
|
@@ -291,6 +320,7 @@ function buildTypeFields(): TaskTypeFields | undefined {
|
|
|
291
320
|
if (stepsToReproduce.value.trim()) f.stepsToReproduce = stepsToReproduce.value.trim()
|
|
292
321
|
return Object.keys(f).length ? f : undefined
|
|
293
322
|
}
|
|
323
|
+
if (taskType.value === 'bug-fishing') return buildBugFishingFields()
|
|
294
324
|
if (taskType.value === 'spike') {
|
|
295
325
|
const f: TaskTypeFields = {}
|
|
296
326
|
// `v-model.number` on a cleared number input yields '' (not undefined), which would
|
|
@@ -420,6 +450,7 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
|
|
|
420
450
|
document: 'pl_document',
|
|
421
451
|
review: 'pl_review',
|
|
422
452
|
media: 'pl_media',
|
|
453
|
+
'bug-fishing': 'pl_bug_fishing',
|
|
423
454
|
}
|
|
424
455
|
/**
|
|
425
456
|
* The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
|
|
@@ -564,6 +595,8 @@ watch(open, (isOpen) => {
|
|
|
564
595
|
severity.value = ''
|
|
565
596
|
stepsToReproduce.value = ''
|
|
566
597
|
timeboxHours.value = undefined
|
|
598
|
+
fishingPhaseIds.value = []
|
|
599
|
+
fishingFocus.value = ''
|
|
567
600
|
spikeResearchQuestion.value = ''
|
|
568
601
|
spikeSuccessCriteria.value = ''
|
|
569
602
|
spikeOptionsToCompare.value = ''
|
|
@@ -1027,6 +1060,53 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1027
1060
|
</UFormField>
|
|
1028
1061
|
</div>
|
|
1029
1062
|
|
|
1063
|
+
<!-- Bug-fishing expedition. Both fields NARROW a hunt that otherwise covers every
|
|
1064
|
+
angle, which is why neither is required and why the angle list is rendered as
|
|
1065
|
+
opt-OUT checkboxes rather than an empty multi-select: leaving it alone has to
|
|
1066
|
+
mean "fish everything", not "fish nothing". -->
|
|
1067
|
+
<div v-else-if="taskType === 'bug-fishing'" class="space-y-3">
|
|
1068
|
+
<UFormField
|
|
1069
|
+
:label="t('board.addTask.bugFishingFields.angles.label')"
|
|
1070
|
+
:hint="t('board.addTask.optional')"
|
|
1071
|
+
:description="t('board.addTask.bugFishingFields.angles.hint')"
|
|
1072
|
+
>
|
|
1073
|
+
<div class="grid gap-1.5 sm:grid-cols-2">
|
|
1074
|
+
<label
|
|
1075
|
+
v-for="phase in BUG_FISHING_PHASES"
|
|
1076
|
+
:key="phase.id"
|
|
1077
|
+
class="flex items-start gap-2 rounded-md px-1.5 py-1 text-[12px] hover:bg-slate-800/40"
|
|
1078
|
+
>
|
|
1079
|
+
<input
|
|
1080
|
+
v-model="fishingPhaseIds"
|
|
1081
|
+
type="checkbox"
|
|
1082
|
+
:value="phase.id"
|
|
1083
|
+
class="mt-0.5 accent-sky-500"
|
|
1084
|
+
:data-testid="`add-task-fishing-angle-${phase.id}`"
|
|
1085
|
+
/>
|
|
1086
|
+
<span class="min-w-0">
|
|
1087
|
+
<span class="block text-slate-200">{{ phase.title }}</span>
|
|
1088
|
+
<span class="block text-[11px] text-slate-500">{{ phase.goal }}</span>
|
|
1089
|
+
</span>
|
|
1090
|
+
</label>
|
|
1091
|
+
</div>
|
|
1092
|
+
<p v-if="fishingPhaseIds.length === 0" class="mt-1.5 text-[11px] text-slate-500">
|
|
1093
|
+
{{ t('board.addTask.bugFishingFields.angles.allSelected') }}
|
|
1094
|
+
</p>
|
|
1095
|
+
</UFormField>
|
|
1096
|
+
<UFormField
|
|
1097
|
+
:label="t('board.addTask.bugFishingFields.focus.label')"
|
|
1098
|
+
:hint="t('board.addTask.optional')"
|
|
1099
|
+
>
|
|
1100
|
+
<UTextarea
|
|
1101
|
+
v-model="fishingFocus"
|
|
1102
|
+
:rows="2"
|
|
1103
|
+
autoresize
|
|
1104
|
+
:placeholder="t('board.addTask.bugFishingFields.focus.placeholder')"
|
|
1105
|
+
class="w-full"
|
|
1106
|
+
/>
|
|
1107
|
+
</UFormField>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1030
1110
|
<div v-else-if="taskType === 'spike'" class="space-y-3">
|
|
1031
1111
|
<UFormField :label="t('board.addTask.timebox')">
|
|
1032
1112
|
<UInput
|
|
@@ -139,17 +139,43 @@ const selectablePipelines = computed(() =>
|
|
|
139
139
|
)
|
|
140
140
|
const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Whether a pipeline RUNS a step of this kind: present in its `agentKinds` AND not disabled.
|
|
144
|
+
*
|
|
145
|
+
* The three questions this modal asks about the picked pipeline (does it fish, does it file a
|
|
146
|
+
* ticket, does it pull from the tracker) are the same question about three kinds, and each of
|
|
147
|
+
* them has to honour the disabled half: a step somebody turned off imposes nothing, so demanding
|
|
148
|
+
* its configuration would block a schedule on a step that will not run.
|
|
149
|
+
*/
|
|
150
|
+
function hasEnabledStep(
|
|
151
|
+
pipeline: { agentKinds: string[]; enabled?: boolean[] } | null | undefined,
|
|
152
|
+
kind: string,
|
|
153
|
+
): boolean {
|
|
154
|
+
if (!pipeline) return false
|
|
155
|
+
return pipeline.agentKinds.some((k, i) => k === kind && pipeline.enabled?.[i] !== false)
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
// Infer the template from the picked pipeline so the backend seeds the right block
|
|
143
159
|
// description (and so we know to show the tracker config).
|
|
144
160
|
//
|
|
145
161
|
// Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
|
|
146
|
-
// way
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
162
|
+
// way. `dep-update` and `tech-debt` were both retired from the catalog (the first was the
|
|
163
|
+
// ordinary build tail under a recurring name, the second that tail behind an audit head), so
|
|
164
|
+
// those schedules now run an ordinary build rung — which is also what every generic schedule
|
|
165
|
+
// runs, so inferring a template from it would mislabel all of them. Both templates survive for an
|
|
166
|
+
// explicit API caller; see `scheduleTemplateSchema`.
|
|
167
|
+
//
|
|
168
|
+
// The expedition is read off its own step KIND rather than off `pl_bug_fishing`'s id, for the
|
|
169
|
+
// reason `filesTicket` below states: an id keys on the one preset that ships today and misses
|
|
170
|
+
// every pipeline a workspace composes around the same step, and the whole seed description is
|
|
171
|
+
// about what a `bug-fisher` pass does.
|
|
172
|
+
const isBugFishing = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-fisher'))
|
|
151
173
|
const template = computed<ScheduleTemplate>(() =>
|
|
152
|
-
|
|
174
|
+
isBugFishing.value
|
|
175
|
+
? 'bug-fishing'
|
|
176
|
+
: pipelineId.value === 'pl_bug_triage'
|
|
177
|
+
? 'bug-triage'
|
|
178
|
+
: 'custom',
|
|
153
179
|
)
|
|
154
180
|
/**
|
|
155
181
|
* Whether the picked pipeline FILES a ticket (an enabled `tracker` step), so the schedule's first
|
|
@@ -159,24 +185,12 @@ const template = computed<ScheduleTemplate>(() =>
|
|
|
159
185
|
* `analysis` + `tracker` head. Keying on the id would have offered the tracker config to exactly
|
|
160
186
|
* the one pipeline that no longer exists, and to none of the pipelines that now do this work.
|
|
161
187
|
*/
|
|
162
|
-
const filesTicket = computed(() =>
|
|
163
|
-
const pipeline = selectedPipeline.value
|
|
164
|
-
if (!pipeline) return false
|
|
165
|
-
return pipeline.agentKinds.some(
|
|
166
|
-
(kind, i) => kind === 'tracker' && pipeline.enabled?.[i] !== false,
|
|
167
|
-
)
|
|
168
|
-
})
|
|
188
|
+
const filesTicket = computed(() => hasEnabledStep(selectedPipeline.value, 'tracker'))
|
|
169
189
|
|
|
170
190
|
// A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
|
|
171
191
|
// the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
|
|
172
192
|
// (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
|
|
173
|
-
const isBugIntake = computed(() =>
|
|
174
|
-
const pipeline = selectedPipeline.value
|
|
175
|
-
if (!pipeline) return false
|
|
176
|
-
return pipeline.agentKinds.some(
|
|
177
|
-
(kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
|
|
178
|
-
)
|
|
179
|
-
})
|
|
193
|
+
const isBugIntake = computed(() => hasEnabledStep(selectedPipeline.value, 'bug-intake'))
|
|
180
194
|
/**
|
|
181
195
|
* Whether the intake section is shown, and in which DISPATCH mode — both DERIVED from the picked
|
|
182
196
|
* pipeline rather than chosen, because the two modes are not interchangeable:
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
serviceDirectoryLeaf,
|
|
4
|
+
serviceDirectoryParent,
|
|
5
|
+
} from '~/components/bootstrap/BootstrapModal.logic'
|
|
6
|
+
|
|
7
|
+
// The rule these two pin is what makes the field browsable AND typable at once: the tree hands
|
|
8
|
+
// back the folder it was standing in plus the leaf, so a name someone typed has to survive a
|
|
9
|
+
// trip through the tree. Reading the leaf off the service name instead would silently discard it.
|
|
10
|
+
|
|
11
|
+
describe('serviceDirectoryLeaf', () => {
|
|
12
|
+
it('is the typed path’s own last segment, not the service name', () => {
|
|
13
|
+
expect(serviceDirectoryLeaf('services/billing', 'payments')).toBe('billing')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('treats a bare name as the leaf (a directory at the repo root)', () => {
|
|
17
|
+
expect(serviceDirectoryLeaf('billing', 'payments')).toBe('billing')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('survives a trailing slash rather than reading as an empty leaf', () => {
|
|
21
|
+
expect(serviceDirectoryLeaf('services/billing/', 'payments')).toBe('billing')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to the service name while the field is still blank', () => {
|
|
25
|
+
expect(serviceDirectoryLeaf('', 'payments')).toBe('payments')
|
|
26
|
+
expect(serviceDirectoryLeaf(' ', ' payments ')).toBe('payments')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('is empty when neither is known, so the tree can say it has nothing to place', () => {
|
|
30
|
+
expect(serviceDirectoryLeaf('', '')).toBe('')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('serviceDirectoryParent', () => {
|
|
35
|
+
it('is the folder the typed path sits in', () => {
|
|
36
|
+
expect(serviceDirectoryParent('packages/services/billing')).toBe('packages/services')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('is the repo root for a bare name', () => {
|
|
40
|
+
expect(serviceDirectoryParent('billing')).toBe('')
|
|
41
|
+
expect(serviceDirectoryParent('')).toBe('')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { repoPathSegments } from '~/utils/repoPath'
|
|
2
|
+
|
|
3
|
+
// The pure half of the bootstrap launch form's monorepo service-directory field. That field
|
|
4
|
+
// holds one string but carries two decisions: what the new directory is CALLED and WHERE in the
|
|
5
|
+
// repo it sits. Browsing the repo tree answers only the second, so it rewrites the parent and
|
|
6
|
+
// keeps the leaf, which means both halves have to be readable off the typed value on their own.
|
|
7
|
+
// Extracted for the reason every `*.logic.ts` here is: a decision worth a test should not need a
|
|
8
|
+
// mounted component to reach.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the new directory is called: the last segment of the typed path.
|
|
12
|
+
*
|
|
13
|
+
* Falls back to the service name, which is what the field's own seeding watcher would have put
|
|
14
|
+
* there anyway, so opening the tree before typing a path still has a name to place. Empty only
|
|
15
|
+
* when NEITHER is known, and that answer is load-bearing: with nothing to place, the tree can
|
|
16
|
+
* decide nothing and says so rather than offering picks that compose a bare folder path.
|
|
17
|
+
*/
|
|
18
|
+
export function serviceDirectoryLeaf(directory: string, serviceName: string): string {
|
|
19
|
+
return repoPathSegments(directory).at(-1) ?? serviceName.trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The folder the typed path sits in, and so where the tree should OPEN: empty for a bare name,
|
|
24
|
+
* because a name with no parent is a directory at the repo root.
|
|
25
|
+
*/
|
|
26
|
+
export function serviceDirectoryParent(directory: string): string {
|
|
27
|
+
return repoPathSegments(directory).slice(0, -1).join('/')
|
|
28
|
+
}
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// architecture, or from scratch following a freeform prompt. The modal pairs the
|
|
6
6
|
// launch form with the managed base list.
|
|
7
7
|
import type { BootstrapStatus, FrameRepoType, ReferenceArchitecture } from '~/types/domain'
|
|
8
|
+
import {
|
|
9
|
+
serviceDirectoryLeaf,
|
|
10
|
+
serviceDirectoryParent,
|
|
11
|
+
} from '~/components/bootstrap/BootstrapModal.logic'
|
|
12
|
+
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
8
13
|
import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
|
|
9
14
|
import { appInstallationManageUrl, newRepoUrl, VCS_PROVIDER_LABELS } from '~/utils/vcs'
|
|
10
15
|
|
|
@@ -108,20 +113,43 @@ const monorepoRepoItems = computed(() =>
|
|
|
108
113
|
github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
|
|
109
114
|
)
|
|
110
115
|
|
|
111
|
-
//
|
|
112
|
-
// directory, so a value that could escape the checkout is refused here
|
|
116
|
+
// `repoPathSegments` is the backend's `normalizeServiceDirectory` reduction: the path becomes
|
|
117
|
+
// an agent's working directory, so a value that could escape the checkout is refused here
|
|
118
|
+
// rather than at the API.
|
|
119
|
+
const directorySegments = computed(() => repoPathSegments(monorepoDirectory.value))
|
|
113
120
|
const directoryError = computed<string | undefined>(() => {
|
|
114
|
-
|
|
115
|
-
if (!value) return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.filter((s) => s && s !== '.')
|
|
120
|
-
if (!segments.length) return t('bootstrap.monorepo.directory.error.empty')
|
|
121
|
-
if (segments.some((s) => s === '..')) return t('bootstrap.monorepo.directory.error.escapes')
|
|
121
|
+
if (!monorepoDirectory.value.trim()) return undefined
|
|
122
|
+
if (!directorySegments.value.length) return t('bootstrap.monorepo.directory.error.empty')
|
|
123
|
+
if (directorySegments.value.some((seg) => seg === '..')) {
|
|
124
|
+
return t('bootstrap.monorepo.directory.error.escapes')
|
|
125
|
+
}
|
|
122
126
|
return undefined
|
|
123
127
|
})
|
|
124
128
|
|
|
129
|
+
// ---- exploring the monorepo for the directory's home -----------------------
|
|
130
|
+
// The target must NOT exist, so nothing in the tree can BE it: the tree picks the enclosing
|
|
131
|
+
// folder and hands back that folder plus the leaf (see `BootstrapModal.logic`, which owns the
|
|
132
|
+
// two readings of the typed value).
|
|
133
|
+
const directoryLeaf = computed(() => serviceDirectoryLeaf(monorepoDirectory.value, repoName.value))
|
|
134
|
+
const browsingDirectory = ref(false)
|
|
135
|
+
// The folder the tree opens at, captured when the browser is OPENED rather than read live off
|
|
136
|
+
// the field: as a computed it would re-navigate the listing on every keystroke in the input.
|
|
137
|
+
const directoryBrowseStart = ref('')
|
|
138
|
+
|
|
139
|
+
function toggleDirectoryBrowse() {
|
|
140
|
+
if (!browsingDirectory.value) {
|
|
141
|
+
directoryBrowseStart.value = serviceDirectoryParent(monorepoDirectory.value)
|
|
142
|
+
}
|
|
143
|
+
browsingDirectory.value = !browsingDirectory.value
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The tree emits the composed path: the folder it was standing in plus the leaf it was given. */
|
|
147
|
+
function placeDirectory(path: string | undefined) {
|
|
148
|
+
if (!path) return
|
|
149
|
+
monorepoDirectory.value = path
|
|
150
|
+
browsingDirectory.value = false
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
// Landing in a monorepo needs no NEW repository, so the repo name is the SERVICE's name (and
|
|
126
154
|
// seeds the directory's leaf); the create-repo affordances below are for the other target.
|
|
127
155
|
watch([intoMonorepo, repoName], ([into, name]) => {
|
|
@@ -332,6 +360,7 @@ async function launch() {
|
|
|
332
360
|
description.value = ''
|
|
333
361
|
instructions.value = ''
|
|
334
362
|
monorepoDirectory.value = ''
|
|
363
|
+
browsingDirectory.value = false
|
|
335
364
|
// Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
|
|
336
365
|
selectedType.value = 'service'
|
|
337
366
|
// The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
|
|
@@ -550,11 +579,49 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
|
|
|
550
579
|
required
|
|
551
580
|
:error="directoryError"
|
|
552
581
|
>
|
|
553
|
-
<
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
582
|
+
<div class="space-y-2">
|
|
583
|
+
<div class="flex items-center gap-2">
|
|
584
|
+
<UInput
|
|
585
|
+
v-model="monorepoDirectory"
|
|
586
|
+
:placeholder="t('bootstrap.monorepo.directory.placeholder')"
|
|
587
|
+
class="flex-1"
|
|
588
|
+
/>
|
|
589
|
+
<UButton
|
|
590
|
+
v-if="monorepoRepoId !== undefined"
|
|
591
|
+
variant="soft"
|
|
592
|
+
color="neutral"
|
|
593
|
+
icon="i-lucide-folder-search"
|
|
594
|
+
:title="t('bootstrap.monorepo.directory.browse')"
|
|
595
|
+
:aria-label="t('bootstrap.monorepo.directory.browse')"
|
|
596
|
+
data-testid="bootstrap-directory-browse"
|
|
597
|
+
@click="toggleDirectoryBrowse()"
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<!-- The tree answers WHERE, never WHAT: with no name to place yet it could
|
|
602
|
+
decide nothing, so say that instead of listing a repo for nothing. -->
|
|
603
|
+
<div
|
|
604
|
+
v-if="browsingDirectory && monorepoRepoId !== undefined"
|
|
605
|
+
class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
|
|
606
|
+
>
|
|
607
|
+
<p class="mb-2 text-xs text-slate-400">
|
|
608
|
+
{{
|
|
609
|
+
directoryLeaf
|
|
610
|
+
? t('bootstrap.monorepo.directory.browseHint')
|
|
611
|
+
: t('bootstrap.monorepo.directory.browseNeedsName')
|
|
612
|
+
}}
|
|
613
|
+
</p>
|
|
614
|
+
<RepoTreeBrowser
|
|
615
|
+
v-if="directoryLeaf"
|
|
616
|
+
:repo-github-id="monorepoRepoId"
|
|
617
|
+
mode="dir"
|
|
618
|
+
:new-dir-name="directoryLeaf"
|
|
619
|
+
:model-value="monorepoDirectory"
|
|
620
|
+
:start-path="directoryBrowseStart"
|
|
621
|
+
@update:model-value="placeDirectory"
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
558
625
|
</UFormField>
|
|
559
626
|
</template>
|
|
560
627
|
|