@cat-factory/app 0.139.0 → 0.141.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 +41 -48
- package/app/components/documents/ContextDocumentPicker.vue +89 -70
- package/app/components/documents/RepoContextDocPicker.vue +277 -0
- package/app/components/focus/BlockFocusView.vue +4 -3
- package/app/components/github/RepoTreeBrowser.vue +19 -10
- package/app/components/palettes/AgentPalette.vue +13 -5
- package/app/components/panels/inspector/TaskRunSettings.vue +21 -27
- package/app/components/pipeline/PipelineBuilder.vue +65 -3
- package/app/components/pipeline/PipelinePicker.vue +120 -0
- package/app/components/pipeline/PipelinePreview.vue +49 -0
- package/app/composables/api/github.ts +8 -0
- package/app/composables/useContextLinking.spec.ts +138 -0
- package/app/composables/useContextLinking.ts +120 -8
- package/app/composables/useCopyToClipboard.spec.ts +27 -0
- package/app/composables/useCopyToClipboard.ts +17 -1
- package/app/stores/github.ts +24 -0
- package/app/stores/pipelines.ts +24 -1
- package/app/types/domain.ts +1 -0
- package/app/utils/pipeline.spec.ts +66 -0
- package/app/utils/pipeline.ts +43 -4
- package/i18n/locales/de.json +31 -1
- package/i18n/locales/en.json +31 -1
- package/i18n/locales/es.json +31 -1
- package/i18n/locales/fr.json +31 -1
- package/i18n/locales/he.json +31 -1
- package/i18n/locales/it.json +31 -1
- package/i18n/locales/ja.json +31 -1
- package/i18n/locales/pl.json +31 -1
- package/i18n/locales/tr.json +31 -1
- package/i18n/locales/uk.json +31 -1
- package/package.json +2 -2
|
@@ -35,7 +35,7 @@ const agentConfig = useAgentConfigStore()
|
|
|
35
35
|
const toast = useToast()
|
|
36
36
|
const { t } = useI18n()
|
|
37
37
|
|
|
38
|
-
const { linkPending } = useContextLinking()
|
|
38
|
+
const { linkPending, presentLinkFailures } = useContextLinking()
|
|
39
39
|
|
|
40
40
|
const open = computed({
|
|
41
41
|
get: () => ui.addTaskContainerId !== null,
|
|
@@ -288,35 +288,34 @@ const selectedModelPresetLabel = computed(() => {
|
|
|
288
288
|
|
|
289
289
|
// Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
|
|
290
290
|
// UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
|
|
291
|
-
// hide `'recurring'`-only pipelines
|
|
291
|
+
// hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and,
|
|
292
|
+
// for a `document` task, every non-document pipeline (it authors a doc — only document pipelines
|
|
293
|
+
// are relevant, per the `purpose` classifier). Re-filters as the chosen task type changes.
|
|
292
294
|
const selectablePipelines = computed(() =>
|
|
293
|
-
pipelines.pipelines.filter((p) =>
|
|
295
|
+
pipelines.pipelines.filter((p) =>
|
|
296
|
+
pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
|
|
297
|
+
),
|
|
294
298
|
)
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
() => pipelines.getPipeline(pipelineId.value)?.name ?? t('board.addTask.chooseAtRunTime'),
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
// Picking the Ralph loop task type auto-selects its pipeline, so the per-task validation
|
|
314
|
-
// command + iteration budget (contributed by the `ralph` agent) surface immediately — the
|
|
315
|
-
// loop is meaningless without them, so "choose at run time" would be a dead end here.
|
|
299
|
+
// Some task types want their type-default pipeline surfaced in the modal up front, so picking the
|
|
300
|
+
// type auto-selects it (the user can still change it among the still-offered pipelines). This is a
|
|
301
|
+
// DELIBERATE SUBSET of the backend `defaultPipelineIdForTaskType` — only the types whose default
|
|
302
|
+
// must appear in the form BEFORE creation:
|
|
303
|
+
// - `ralph` needs its preset so the per-task validation command + iteration budget the `ralph`
|
|
304
|
+
// agent contributes surface for editing ("choose at run time" would be a dead end);
|
|
305
|
+
// - a `document` task defaults to `pl_document` so its document-only picker (the `purpose` gate
|
|
306
|
+
// hides every non-document pipeline) is never rendered empty.
|
|
307
|
+
// The other typed defaults (spike/review) carry no up-front config and don't narrow their picker,
|
|
308
|
+
// so the modal leaves `pipelineId` unset and `BoardService` applies the backend type-default at
|
|
309
|
+
// creation. Keep these ids in step with the backend helper.
|
|
310
|
+
const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
|
|
311
|
+
ralph: 'pl_ralph',
|
|
312
|
+
document: 'pl_document',
|
|
313
|
+
}
|
|
316
314
|
watch(taskType, (next) => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
315
|
+
const preset = DEFAULT_PIPELINE_FOR_TYPE[next]
|
|
316
|
+
if (!preset) return
|
|
317
|
+
const match = pipelines.pipelines.find((p) => p.id === preset)
|
|
318
|
+
if (match) pipelineId.value = match.id
|
|
320
319
|
})
|
|
321
320
|
|
|
322
321
|
// Task-level agent config contributed by the selected pipeline's agents (e.g. the
|
|
@@ -473,7 +472,10 @@ watch(open, (isOpen) => {
|
|
|
473
472
|
delete docKindFieldValues[key]
|
|
474
473
|
riskPolicyId.value = ''
|
|
475
474
|
modelPresetId.value = ''
|
|
476
|
-
|
|
475
|
+
// Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document
|
|
476
|
+
// repo opens with `pl_document` pre-selected rather than empty. This runs AFTER the `taskType`
|
|
477
|
+
// watcher fired during this reset, so it is the authoritative default (see DEFAULT_PIPELINE_FOR_TYPE).
|
|
478
|
+
pipelineId.value = DEFAULT_PIPELINE_FOR_TYPE[taskType.value] ?? ''
|
|
477
479
|
agentConfigValues.value = {}
|
|
478
480
|
pendingContext.value = []
|
|
479
481
|
showDocPicker.value = false
|
|
@@ -578,14 +580,10 @@ async function add() {
|
|
|
578
580
|
...(technical.value ? { technical: true } : {}),
|
|
579
581
|
})
|
|
580
582
|
if (block) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
icon: 'i-lucide-triangle-alert',
|
|
586
|
-
color: 'warning',
|
|
587
|
-
})
|
|
588
|
-
}
|
|
583
|
+
// Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
|
|
584
|
+
// permission/visibility error, a not-found doc, …) instead of a bare count, plus a
|
|
585
|
+
// one-click "Copy details" for a bug report.
|
|
586
|
+
presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
|
|
589
587
|
}
|
|
590
588
|
ui.closeAddTask()
|
|
591
589
|
} catch (e) {
|
|
@@ -895,18 +893,13 @@ async function add() {
|
|
|
895
893
|
|
|
896
894
|
<div class="grid grid-cols-2 gap-3">
|
|
897
895
|
<UFormField :label="t('board.addTask.pipeline')">
|
|
898
|
-
<
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
class="w-full justify-between"
|
|
906
|
-
>
|
|
907
|
-
{{ selectedPipelineLabel }}
|
|
908
|
-
</UButton>
|
|
909
|
-
</UDropdownMenu>
|
|
896
|
+
<PipelinePicker
|
|
897
|
+
:model-value="pipelineId"
|
|
898
|
+
:options="selectablePipelines"
|
|
899
|
+
:none-label="t('board.addTask.chooseAtRunTime')"
|
|
900
|
+
trigger-class="w-full justify-between"
|
|
901
|
+
@update:model-value="pipelineId = $event"
|
|
902
|
+
/>
|
|
910
903
|
</UFormField>
|
|
911
904
|
|
|
912
905
|
<UFormField :label="t('board.addTask.mergePolicy')">
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// it's fetched + persisted before linking. Mirrors ContextIssuePicker.
|
|
11
11
|
import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
|
|
12
12
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
13
|
+
import RepoContextDocPicker from '~/components/documents/RepoContextDocPicker.vue'
|
|
14
|
+
|
|
15
|
+
// Repo-backed document sources pick a FILE out of a repository (repo search → file
|
|
16
|
+
// search / tree browse) instead of the generic free-text catalogue search. Today only
|
|
17
|
+
// `github` (which transparently covers GitLab via the VCS adapter) is repo-backed.
|
|
18
|
+
const REPO_SOURCES = new Set<DocumentSourceKind>(['github'])
|
|
13
19
|
|
|
14
20
|
const props = defineProps<{
|
|
15
21
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
@@ -32,6 +38,8 @@ const descriptor = computed(() =>
|
|
|
32
38
|
source.value ? documents.descriptorFor(source.value) : undefined,
|
|
33
39
|
)
|
|
34
40
|
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
41
|
+
// A repo-backed source swaps the whole free-text search body for the repo→file picker.
|
|
42
|
+
const isRepoSource = computed(() => !!source.value && REPO_SOURCES.has(source.value))
|
|
35
43
|
|
|
36
44
|
const query = ref('')
|
|
37
45
|
const results = ref<DocumentSearchResult[]>([])
|
|
@@ -176,81 +184,92 @@ onMounted(() => {
|
|
|
176
184
|
class="w-full"
|
|
177
185
|
/>
|
|
178
186
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
searchable
|
|
187
|
-
? t('documents.picker.searchPlaceholder')
|
|
188
|
-
: (descriptor?.refPlaceholder ?? t('documents.picker.refPlaceholder'))
|
|
189
|
-
"
|
|
190
|
-
@keydown.enter="refRow && pickRef(refRow)"
|
|
187
|
+
<!-- Repo-backed source (GitHub / GitLab): pick a repository, then a file. -->
|
|
188
|
+
<RepoContextDocPicker
|
|
189
|
+
v-if="isRepoSource && source"
|
|
190
|
+
:source="source!"
|
|
191
|
+
:icon="icon"
|
|
192
|
+
:chosen-keys="chosenKeys"
|
|
193
|
+
@pick="(item: PendingContext) => emit('pick', item)"
|
|
191
194
|
/>
|
|
192
195
|
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
<template v-else>
|
|
197
|
+
<UInput
|
|
198
|
+
v-model="query"
|
|
199
|
+
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
200
|
+
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
201
|
+
size="sm"
|
|
202
|
+
class="w-full"
|
|
203
|
+
:placeholder="
|
|
204
|
+
searchable
|
|
205
|
+
? t('documents.picker.searchPlaceholder')
|
|
206
|
+
: (descriptor?.refPlaceholder ?? t('documents.picker.refPlaceholder'))
|
|
207
|
+
"
|
|
208
|
+
@keydown.enter="refRow && pickRef(refRow)"
|
|
209
|
+
/>
|
|
196
210
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
v-for="d in importedRows"
|
|
201
|
-
:key="`imp:${d.externalId}`"
|
|
202
|
-
type="button"
|
|
203
|
-
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
|
|
204
|
-
@click="pickImported(d.externalId, d.title, d.excerpt)"
|
|
205
|
-
>
|
|
206
|
-
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
207
|
-
<span class="truncate">{{ d.title }}</span>
|
|
208
|
-
<UBadge color="neutral" variant="soft" size="xs" class="ms-auto shrink-0">{{
|
|
209
|
-
t('documents.picker.importedBadge')
|
|
210
|
-
}}</UBadge>
|
|
211
|
-
</button>
|
|
211
|
+
<p v-if="searchError" class="px-1 text-[11px] text-amber-400">
|
|
212
|
+
{{ t('documents.picker.searchFailed', { error: searchError }) }}
|
|
213
|
+
</p>
|
|
212
214
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
215
|
+
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
216
|
+
<!-- Already-imported documents (linked directly, no re-fetch). -->
|
|
217
|
+
<button
|
|
218
|
+
v-for="d in importedRows"
|
|
219
|
+
:key="`imp:${d.externalId}`"
|
|
220
|
+
type="button"
|
|
221
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
|
|
222
|
+
@click="pickImported(d.externalId, d.title, d.excerpt)"
|
|
223
|
+
>
|
|
224
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
225
|
+
<span class="truncate">{{ d.title }}</span>
|
|
226
|
+
<UBadge color="neutral" variant="soft" size="xs" class="ms-auto shrink-0">{{
|
|
227
|
+
t('documents.picker.importedBadge')
|
|
228
|
+
}}</UBadge>
|
|
229
|
+
</button>
|
|
224
230
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
<span class="text-slate-200">{{ refRow }}</span>
|
|
237
|
-
</template>
|
|
238
|
-
</i18n-t>
|
|
239
|
-
</span>
|
|
240
|
-
</button>
|
|
231
|
+
<!-- Source search hits (imported on add). -->
|
|
232
|
+
<button
|
|
233
|
+
v-for="r in searchRows"
|
|
234
|
+
:key="`hit:${r.externalId}`"
|
|
235
|
+
type="button"
|
|
236
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
|
|
237
|
+
@click="pickSearch(r)"
|
|
238
|
+
>
|
|
239
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
240
|
+
<span class="truncate">{{ r.title }}</span>
|
|
241
|
+
</button>
|
|
241
242
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
243
|
+
<!-- Explicit URL/ID reference (imported on add). -->
|
|
244
|
+
<button
|
|
245
|
+
v-if="refRow"
|
|
246
|
+
type="button"
|
|
247
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
|
|
248
|
+
@click="pickRef(refRow)"
|
|
249
|
+
>
|
|
250
|
+
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
251
|
+
<span class="truncate">
|
|
252
|
+
<i18n-t keypath="documents.picker.attachByReference" scope="global">
|
|
253
|
+
<template #ref>
|
|
254
|
+
<span class="text-slate-200">{{ refRow }}</span>
|
|
255
|
+
</template>
|
|
256
|
+
</i18n-t>
|
|
257
|
+
</span>
|
|
258
|
+
</button>
|
|
259
|
+
|
|
260
|
+
<EmptyState
|
|
261
|
+
v-if="empty"
|
|
262
|
+
compact
|
|
263
|
+
icon="i-lucide-file-search"
|
|
264
|
+
:title="
|
|
265
|
+
query.trim()
|
|
266
|
+
? t('documents.picker.noMatches')
|
|
267
|
+
: searchable
|
|
268
|
+
? t('documents.picker.emptySearchable')
|
|
269
|
+
: t('documents.picker.emptyRefOnly')
|
|
270
|
+
"
|
|
271
|
+
/>
|
|
272
|
+
</div>
|
|
273
|
+
</template>
|
|
255
274
|
</div>
|
|
256
275
|
</template>
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Repo-backed context-document picker: the GitHub / GitLab branch of
|
|
3
|
+
// ContextDocumentPicker. A repo-doc reference is a single file in a repository the
|
|
4
|
+
// workspace's App (or PAT) can reach, so instead of the generic free-text search
|
|
5
|
+
// this flow is: pick a repository, then pick one or more FILES from it — either by
|
|
6
|
+
// searching the whole tree by path, or by browsing it with the same tree browser the
|
|
7
|
+
// monorepo add-service flow uses. Each picked file is STAGED (emitted as a
|
|
8
|
+
// `PendingContext`, imported + linked once the block exists); already-staged files
|
|
9
|
+
// are shown "added" and can't be re-picked, mirroring the other context pickers.
|
|
10
|
+
//
|
|
11
|
+
// The repo search reuses `useRepoSearch` (debounce + stale-guard + per-instance
|
|
12
|
+
// results, so it never clobbers another picker's list) and the file search reuses the
|
|
13
|
+
// github store's recursive-tree cache (`loadRepoFiles`) so filtering is instant and
|
|
14
|
+
// client-side after one fetch per repo. Nothing here is GitHub-specific beyond the
|
|
15
|
+
// store it rides: the same store transparently returns GitLab projects via the VCS
|
|
16
|
+
// adapter, so this works unchanged on a GitLab deployment.
|
|
17
|
+
import type { DocumentSourceKind, GitHubAvailableRepo, RepoTreeEntry } from '~/types/domain'
|
|
18
|
+
import EmptyState from '~/components/common/EmptyState.vue'
|
|
19
|
+
import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
|
|
20
|
+
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
/** The repo-backed document source (`github`) whose files are being attached. */
|
|
24
|
+
source: DocumentSourceKind
|
|
25
|
+
/** Lucide icon for the source, used on each staged file row. */
|
|
26
|
+
icon: string
|
|
27
|
+
/** contextKeys already staged by the caller, so they're shown "added" / not re-offered. */
|
|
28
|
+
chosenKeys?: string[]
|
|
29
|
+
}>()
|
|
30
|
+
const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
31
|
+
|
|
32
|
+
const { t } = useI18n()
|
|
33
|
+
const github = useGitHubStore()
|
|
34
|
+
|
|
35
|
+
const chosen = computed(() => new Set(props.chosenKeys ?? []))
|
|
36
|
+
|
|
37
|
+
// ---- repo search (reused pattern) ----------------------------------------
|
|
38
|
+
const {
|
|
39
|
+
search: repoSearch,
|
|
40
|
+
query: repoQueryRaw,
|
|
41
|
+
belowMinChars,
|
|
42
|
+
results: repoResults,
|
|
43
|
+
loading: repoLoading,
|
|
44
|
+
reset: resetRepoSearch,
|
|
45
|
+
} = useRepoSearch()
|
|
46
|
+
|
|
47
|
+
const selectedRepoId = ref<number | undefined>(undefined)
|
|
48
|
+
// Captured when picked — the results list is volatile (a later search replaces it), so
|
|
49
|
+
// the owner/name can't be recovered from it after the fact, and we need them to build
|
|
50
|
+
// the `owner/repo:path` external id.
|
|
51
|
+
const selectedRepo = ref<GitHubAvailableRepo | undefined>(undefined)
|
|
52
|
+
|
|
53
|
+
function toRepoItem(r: GitHubAvailableRepo) {
|
|
54
|
+
const suffix = r.private ? t('github.addService.repoLabel.private') : ''
|
|
55
|
+
return { label: `${r.owner}/${r.name}${suffix}`, value: r.githubId }
|
|
56
|
+
}
|
|
57
|
+
const queryMatches = computed(() => (belowMinChars.value ? [] : repoResults.value.map(toRepoItem)))
|
|
58
|
+
const repoMenuItems = computed(() => {
|
|
59
|
+
const matches = queryMatches.value
|
|
60
|
+
if (selectedRepoId.value === undefined) return matches
|
|
61
|
+
if (matches.some((r) => r.value === selectedRepoId.value)) return matches
|
|
62
|
+
return selectedRepo.value ? [toRepoItem(selectedRepo.value), ...matches] : matches
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
watch(selectedRepoId, (id) => {
|
|
66
|
+
if (id === undefined) {
|
|
67
|
+
selectedRepo.value = undefined
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const found = repoResults.value.find((r) => r.githubId === id)
|
|
71
|
+
if (found) selectedRepo.value = found
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function clearRepo() {
|
|
75
|
+
selectedRepoId.value = undefined
|
|
76
|
+
selectedRepo.value = undefined
|
|
77
|
+
resetRepoSearch()
|
|
78
|
+
fileQuery.value = ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- file selection (browse | search) ------------------------------------
|
|
82
|
+
type FileMode = 'search' | 'browse'
|
|
83
|
+
const fileMode = ref<FileMode>('search')
|
|
84
|
+
|
|
85
|
+
// Files already staged for THIS repo, as repo-root-relative paths — derived from the
|
|
86
|
+
// caller's chosen keys (`document:<source>:<owner>/<repo>:<path>`). Shown disabled in
|
|
87
|
+
// both the tree browser and the search list so a file can't be staged twice.
|
|
88
|
+
const addedPaths = computed<string[]>(() => {
|
|
89
|
+
const repo = selectedRepo.value
|
|
90
|
+
if (!repo) return []
|
|
91
|
+
const prefix = `document:${props.source}:${repo.owner}/${repo.name}:`
|
|
92
|
+
return [...chosen.value].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length))
|
|
93
|
+
})
|
|
94
|
+
const addedSet = computed(() => new Set(addedPaths.value.map(normalizeRepoPath)))
|
|
95
|
+
|
|
96
|
+
// The recursive file listing for the selected repo, loaded + cached on demand for the
|
|
97
|
+
// search box. Loaded lazily when the user first switches to / lands on search.
|
|
98
|
+
const files = computed<RepoTreeEntry[]>(() =>
|
|
99
|
+
selectedRepoId.value !== undefined ? (github.repoFiles[selectedRepoId.value] ?? []) : [],
|
|
100
|
+
)
|
|
101
|
+
const loadingFiles = ref(false)
|
|
102
|
+
const filesError = ref<string | null>(null)
|
|
103
|
+
|
|
104
|
+
async function ensureFilesLoaded() {
|
|
105
|
+
if (selectedRepoId.value === undefined) return
|
|
106
|
+
if (github.repoFiles[selectedRepoId.value]) return
|
|
107
|
+
loadingFiles.value = true
|
|
108
|
+
filesError.value = null
|
|
109
|
+
try {
|
|
110
|
+
await github.loadRepoFiles(selectedRepoId.value)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
filesError.value = e instanceof Error ? e.message : String(e)
|
|
113
|
+
} finally {
|
|
114
|
+
loadingFiles.value = false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Load the file list as soon as a repo is picked (search is the default tab); browsing
|
|
119
|
+
// uses the per-level tree endpoint and needs no preload.
|
|
120
|
+
watch(selectedRepoId, (id) => {
|
|
121
|
+
fileQuery.value = ''
|
|
122
|
+
fileMode.value = 'search'
|
|
123
|
+
if (id !== undefined) void ensureFilesLoaded()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const fileQuery = ref('')
|
|
127
|
+
// Matches are computed client-side from the cached tree (no per-keystroke server call).
|
|
128
|
+
// A query is required so a large repo never renders thousands of rows at once; results
|
|
129
|
+
// are capped for the same reason.
|
|
130
|
+
const FILE_RESULTS_CAP = 50
|
|
131
|
+
// Filter the cached tree ONCE per keystroke, then derive the capped view + the
|
|
132
|
+
// "truncated" flag from that single pass (a large monorepo tree is filtered twice
|
|
133
|
+
// otherwise, once per computed, on every keystroke).
|
|
134
|
+
const fileMatchesAll = computed(() => {
|
|
135
|
+
const q = fileQuery.value.trim().toLowerCase()
|
|
136
|
+
if (!q) return []
|
|
137
|
+
return files.value.filter(
|
|
138
|
+
(f) => !addedSet.value.has(normalizeRepoPath(f.path)) && f.path.toLowerCase().includes(q),
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
const fileMatches = computed(() => fileMatchesAll.value.slice(0, FILE_RESULTS_CAP))
|
|
142
|
+
const fileMatchesTruncated = computed(() => fileMatchesAll.value.length > FILE_RESULTS_CAP)
|
|
143
|
+
|
|
144
|
+
function pickFile(path: string) {
|
|
145
|
+
const repo = selectedRepo.value
|
|
146
|
+
if (!repo || addedSet.value.has(normalizeRepoPath(path))) return
|
|
147
|
+
const clean = normalizeRepoPath(path)
|
|
148
|
+
emit('pick', {
|
|
149
|
+
kind: 'document',
|
|
150
|
+
source: props.source,
|
|
151
|
+
// The GitHub/GitLab doc source's canonical `owner/repo:path` external id.
|
|
152
|
+
externalId: `${repo.owner}/${repo.name}:${clean}`,
|
|
153
|
+
title: clean.split('/').pop() || clean,
|
|
154
|
+
subtitle: `${repo.owner}/${repo.name} · ${clean}`,
|
|
155
|
+
icon: props.icon,
|
|
156
|
+
needsImport: true,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
onMounted(() => {
|
|
161
|
+
// The repo search rides the github store; make sure the App connection is probed so
|
|
162
|
+
// `searchAvailableRepos` sees `connected` (single-flighted — cheap to call).
|
|
163
|
+
github.ensureProbed().catch(() => {})
|
|
164
|
+
})
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<template>
|
|
168
|
+
<div class="space-y-2">
|
|
169
|
+
<!-- 1. pick a repository -->
|
|
170
|
+
<UInputMenu
|
|
171
|
+
v-model="selectedRepoId"
|
|
172
|
+
v-model:search-term="repoSearch"
|
|
173
|
+
:items="repoMenuItems"
|
|
174
|
+
:ignore-filter="true"
|
|
175
|
+
value-key="value"
|
|
176
|
+
:loading="repoLoading"
|
|
177
|
+
icon="i-lucide-search"
|
|
178
|
+
size="sm"
|
|
179
|
+
:placeholder="t('documents.repoPicker.searchRepoPlaceholder')"
|
|
180
|
+
class="w-full"
|
|
181
|
+
>
|
|
182
|
+
<template v-if="selectedRepoId !== undefined" #trailing>
|
|
183
|
+
<UButton
|
|
184
|
+
color="neutral"
|
|
185
|
+
variant="link"
|
|
186
|
+
size="sm"
|
|
187
|
+
icon="i-lucide-x"
|
|
188
|
+
:aria-label="t('documents.repoPicker.clearRepo')"
|
|
189
|
+
@click.stop="clearRepo"
|
|
190
|
+
/>
|
|
191
|
+
</template>
|
|
192
|
+
<template #empty>
|
|
193
|
+
<RepoSearchEmpty
|
|
194
|
+
:below-min-chars="belowMinChars"
|
|
195
|
+
:loading="repoLoading"
|
|
196
|
+
:query="repoQueryRaw"
|
|
197
|
+
/>
|
|
198
|
+
</template>
|
|
199
|
+
</UInputMenu>
|
|
200
|
+
|
|
201
|
+
<!-- 2. pick file(s) from the chosen repo -->
|
|
202
|
+
<template v-if="selectedRepoId !== undefined">
|
|
203
|
+
<div class="flex items-center gap-1">
|
|
204
|
+
<UButton
|
|
205
|
+
size="xs"
|
|
206
|
+
:color="fileMode === 'search' ? 'primary' : 'neutral'"
|
|
207
|
+
:variant="fileMode === 'search' ? 'soft' : 'ghost'"
|
|
208
|
+
icon="i-lucide-search"
|
|
209
|
+
@click="fileMode = 'search'"
|
|
210
|
+
>
|
|
211
|
+
{{ t('documents.repoPicker.searchTab') }}
|
|
212
|
+
</UButton>
|
|
213
|
+
<UButton
|
|
214
|
+
size="xs"
|
|
215
|
+
:color="fileMode === 'browse' ? 'primary' : 'neutral'"
|
|
216
|
+
:variant="fileMode === 'browse' ? 'soft' : 'ghost'"
|
|
217
|
+
icon="i-lucide-folder-tree"
|
|
218
|
+
@click="fileMode = 'browse'"
|
|
219
|
+
>
|
|
220
|
+
{{ t('documents.repoPicker.browseTab') }}
|
|
221
|
+
</UButton>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- search files by path (client-side over the cached recursive tree) -->
|
|
225
|
+
<div v-if="fileMode === 'search'" class="space-y-2">
|
|
226
|
+
<UInput
|
|
227
|
+
v-model="fileQuery"
|
|
228
|
+
:icon="loadingFiles ? 'i-lucide-loader-circle' : 'i-lucide-file-search'"
|
|
229
|
+
:ui="{ leadingIcon: loadingFiles ? 'animate-spin' : '' }"
|
|
230
|
+
size="sm"
|
|
231
|
+
class="w-full"
|
|
232
|
+
:placeholder="t('documents.repoPicker.searchFilesPlaceholder')"
|
|
233
|
+
/>
|
|
234
|
+
<p v-if="filesError" class="px-1 text-[11px] text-amber-400">
|
|
235
|
+
{{ t('documents.repoPicker.filesFailed', { error: filesError }) }}
|
|
236
|
+
</p>
|
|
237
|
+
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
238
|
+
<button
|
|
239
|
+
v-for="f in fileMatches"
|
|
240
|
+
:key="f.path"
|
|
241
|
+
type="button"
|
|
242
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
|
|
243
|
+
@click="pickFile(f.path)"
|
|
244
|
+
>
|
|
245
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
246
|
+
<span class="truncate">{{ f.path }}</span>
|
|
247
|
+
</button>
|
|
248
|
+
<p v-if="fileMatchesTruncated" class="px-2 py-1 text-[11px] text-slate-500">
|
|
249
|
+
{{ t('documents.repoPicker.moreFiles', { count: FILE_RESULTS_CAP }) }}
|
|
250
|
+
</p>
|
|
251
|
+
<EmptyState
|
|
252
|
+
v-if="!loadingFiles && !filesError && fileQuery.trim() && fileMatches.length === 0"
|
|
253
|
+
compact
|
|
254
|
+
icon="i-lucide-file-search"
|
|
255
|
+
:title="t('documents.repoPicker.noFileMatches')"
|
|
256
|
+
/>
|
|
257
|
+
<p
|
|
258
|
+
v-else-if="!loadingFiles && !fileQuery.trim()"
|
|
259
|
+
class="px-2 py-1 text-[11px] text-slate-500"
|
|
260
|
+
>
|
|
261
|
+
{{ t('documents.repoPicker.searchFilesHint') }}
|
|
262
|
+
</p>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<!-- browse the tree, multi-pick files (same browser as the monorepo flow) -->
|
|
267
|
+
<RepoTreeBrowser
|
|
268
|
+
v-else
|
|
269
|
+
:repo-github-id="selectedRepoId"
|
|
270
|
+
mode="file"
|
|
271
|
+
multiple
|
|
272
|
+
:added-paths="addedPaths"
|
|
273
|
+
@toggle="pickFile"
|
|
274
|
+
/>
|
|
275
|
+
</template>
|
|
276
|
+
</div>
|
|
277
|
+
</template>
|
|
@@ -28,12 +28,13 @@ const deps = computed(() =>
|
|
|
28
28
|
(block.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
// Hide UI-testing pipelines when this block's frame has no UI to exercise,
|
|
32
|
-
// pipelines (a manual run of one is refused server-side) —
|
|
31
|
+
// Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
|
|
32
|
+
// pipelines (a manual run of one is refused server-side), and — for a `document` task — every
|
|
33
|
+
// non-document pipeline (per the `purpose` classifier) — see the backend gate.
|
|
33
34
|
const runMenu = computed(() => {
|
|
34
35
|
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
35
36
|
return pipelines.pipelines
|
|
36
|
-
.filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks))
|
|
37
|
+
.filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType))
|
|
37
38
|
.map((p) => ({
|
|
38
39
|
label: p.name,
|
|
39
40
|
icon: 'i-lucide-play',
|