@cat-factory/app 0.139.0 → 0.140.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 +12 -39
- package/app/components/documents/ContextDocumentPicker.vue +89 -70
- package/app/components/documents/RepoContextDocPicker.vue +277 -0
- package/app/components/github/RepoTreeBrowser.vue +19 -10
- package/app/components/panels/inspector/TaskRunSettings.vue +16 -23
- package/app/components/pipeline/PipelineBuilder.vue +10 -0
- 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 +9 -0
- package/app/utils/pipeline.ts +25 -1
- package/i18n/locales/de.json +20 -0
- package/i18n/locales/en.json +20 -0
- package/i18n/locales/es.json +20 -0
- package/i18n/locales/fr.json +20 -0
- package/i18n/locales/he.json +20 -0
- package/i18n/locales/it.json +20 -0
- package/i18n/locales/ja.json +20 -0
- package/i18n/locales/pl.json +20 -0
- package/i18n/locales/tr.json +20 -0
- package/i18n/locales/uk.json +20 -0
- 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,
|
|
@@ -292,24 +292,6 @@ const selectedModelPresetLabel = computed(() => {
|
|
|
292
292
|
const selectablePipelines = computed(() =>
|
|
293
293
|
pipelines.pipelines.filter((p) => pipelineAllowedForManualStart(p, frame.value, board.blocks)),
|
|
294
294
|
)
|
|
295
|
-
const pipelineMenu = computed(() => [
|
|
296
|
-
[
|
|
297
|
-
{
|
|
298
|
-
label: t('board.addTask.chooseAtRunTime'),
|
|
299
|
-
icon: 'i-lucide-rotate-ccw',
|
|
300
|
-
onSelect: () => (pipelineId.value = ''),
|
|
301
|
-
},
|
|
302
|
-
...selectablePipelines.value.map((p) => ({
|
|
303
|
-
label: p.name,
|
|
304
|
-
icon: 'i-lucide-workflow',
|
|
305
|
-
onSelect: () => (pipelineId.value = p.id),
|
|
306
|
-
})),
|
|
307
|
-
],
|
|
308
|
-
])
|
|
309
|
-
const selectedPipelineLabel = computed(
|
|
310
|
-
() => pipelines.getPipeline(pipelineId.value)?.name ?? t('board.addTask.chooseAtRunTime'),
|
|
311
|
-
)
|
|
312
|
-
|
|
313
295
|
// Picking the Ralph loop task type auto-selects its pipeline, so the per-task validation
|
|
314
296
|
// command + iteration budget (contributed by the `ralph` agent) surface immediately — the
|
|
315
297
|
// loop is meaningless without them, so "choose at run time" would be a dead end here.
|
|
@@ -578,14 +560,10 @@ async function add() {
|
|
|
578
560
|
...(technical.value ? { technical: true } : {}),
|
|
579
561
|
})
|
|
580
562
|
if (block) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
icon: 'i-lucide-triangle-alert',
|
|
586
|
-
color: 'warning',
|
|
587
|
-
})
|
|
588
|
-
}
|
|
563
|
+
// Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
|
|
564
|
+
// permission/visibility error, a not-found doc, …) instead of a bare count, plus a
|
|
565
|
+
// one-click "Copy details" for a bug report.
|
|
566
|
+
presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
|
|
589
567
|
}
|
|
590
568
|
ui.closeAddTask()
|
|
591
569
|
} catch (e) {
|
|
@@ -895,18 +873,13 @@ async function add() {
|
|
|
895
873
|
|
|
896
874
|
<div class="grid grid-cols-2 gap-3">
|
|
897
875
|
<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>
|
|
876
|
+
<PipelinePicker
|
|
877
|
+
:model-value="pipelineId"
|
|
878
|
+
:options="selectablePipelines"
|
|
879
|
+
:none-label="t('board.addTask.chooseAtRunTime')"
|
|
880
|
+
trigger-class="w-full justify-between"
|
|
881
|
+
@update:model-value="pipelineId = $event"
|
|
882
|
+
/>
|
|
910
883
|
</UFormField>
|
|
911
884
|
|
|
912
885
|
<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>
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
// via `v-model`. The component owns its own navigation/loading state so callers
|
|
8
8
|
// just bind a repo id + mode; it self-loads on mount and when those change.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// Both modes additionally support `multiple`: instead of the single `v-model`
|
|
11
11
|
// path, the caller passes the current `selectedPaths` (a cart) + `addedPaths`
|
|
12
|
-
// (
|
|
13
|
-
// event to add/remove a
|
|
14
|
-
//
|
|
15
|
-
// away never drops
|
|
12
|
+
// (paths already chosen elsewhere, shown disabled) and handles the `toggle`
|
|
13
|
+
// event to add/remove a path. In `dir` mode this accumulates service directories
|
|
14
|
+
// from ANY parent folder (the monorepo add flow); in `file` mode it accumulates
|
|
15
|
+
// context-document files from anywhere in the tree — navigating away never drops
|
|
16
|
+
// earlier picks.
|
|
16
17
|
import type { RepoTreeEntry } from '~/types/domain'
|
|
17
18
|
|
|
18
19
|
const props = withDefaults(
|
|
@@ -23,11 +24,11 @@ const props = withDefaults(
|
|
|
23
24
|
modelValue?: string
|
|
24
25
|
/** Directory to open at (e.g. a monorepo service's subdirectory). */
|
|
25
26
|
startPath?: string
|
|
26
|
-
/**
|
|
27
|
+
/** Accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. Either mode. */
|
|
27
28
|
multiple?: boolean
|
|
28
|
-
/** `
|
|
29
|
+
/** `multiple`: the current cart of picked paths (repo-root-relative). */
|
|
29
30
|
selectedPaths?: string[]
|
|
30
|
-
/** `
|
|
31
|
+
/** `multiple`: paths already chosen elsewhere — listed but not selectable. */
|
|
31
32
|
addedPaths?: string[]
|
|
32
33
|
}>(),
|
|
33
34
|
{ mode: 'dir', startPath: '', multiple: false, selectedPaths: () => [], addedPaths: () => [] },
|
|
@@ -182,14 +183,22 @@ watch(
|
|
|
182
183
|
<button
|
|
183
184
|
type="button"
|
|
184
185
|
class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
|
|
185
|
-
:class="
|
|
186
|
+
:class="isPicked(entry.path) ? 'text-primary-400' : 'text-slate-300'"
|
|
187
|
+
:disabled="isAdded(entry.path)"
|
|
186
188
|
@click="pick(entry.path)"
|
|
187
189
|
>
|
|
188
190
|
<UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
|
|
189
191
|
<span class="truncate">{{ entry.name }}</span>
|
|
190
192
|
</button>
|
|
193
|
+
<span
|
|
194
|
+
v-if="isAdded(entry.path)"
|
|
195
|
+
class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
|
|
196
|
+
>
|
|
197
|
+
<UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
|
|
198
|
+
{{ t('github.repoTree.added') }}
|
|
199
|
+
</span>
|
|
191
200
|
<UIcon
|
|
192
|
-
v-if="
|
|
201
|
+
v-else-if="isPicked(entry.path)"
|
|
193
202
|
name="i-lucide-check"
|
|
194
203
|
class="h-4 w-4 shrink-0 text-primary-400"
|
|
195
204
|
/>
|
|
@@ -142,20 +142,6 @@ const selectablePipelines = computed(() =>
|
|
|
142
142
|
pipelineAllowedForManualStart(p, taskFrame.value, board.blocks),
|
|
143
143
|
),
|
|
144
144
|
)
|
|
145
|
-
const pipelineMenu = computed(() => [
|
|
146
|
-
[
|
|
147
|
-
{
|
|
148
|
-
label: t('inspector.runSettings.noDefault'),
|
|
149
|
-
icon: 'i-lucide-rotate-ccw',
|
|
150
|
-
onSelect: () => setPipeline(''),
|
|
151
|
-
},
|
|
152
|
-
...selectablePipelines.value.map((p) => ({
|
|
153
|
-
label: p.name,
|
|
154
|
-
icon: 'i-lucide-workflow',
|
|
155
|
-
onSelect: () => setPipeline(p.id),
|
|
156
|
-
})),
|
|
157
|
-
],
|
|
158
|
-
])
|
|
159
145
|
function setPipeline(id: string) {
|
|
160
146
|
board.updateBlock(props.block.id, { pipelineId: id })
|
|
161
147
|
}
|
|
@@ -271,15 +257,22 @@ const technicalLabel = computed(() => {
|
|
|
271
257
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
272
258
|
{{ t('inspector.runSettings.pipeline') }}
|
|
273
259
|
</span>
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
260
|
+
<PipelinePicker
|
|
261
|
+
:model-value="block.pipelineId ?? ''"
|
|
262
|
+
:options="selectablePipelines"
|
|
263
|
+
:none-label="t('inspector.runSettings.noDefault')"
|
|
264
|
+
@update:model-value="setPipeline"
|
|
265
|
+
>
|
|
266
|
+
<template #trigger>
|
|
267
|
+
<UButton
|
|
268
|
+
size="xs"
|
|
269
|
+
variant="ghost"
|
|
270
|
+
color="neutral"
|
|
271
|
+
icon="i-lucide-workflow"
|
|
272
|
+
trailing-icon="i-lucide-chevron-down"
|
|
273
|
+
/>
|
|
274
|
+
</template>
|
|
275
|
+
</PipelinePicker>
|
|
283
276
|
</div>
|
|
284
277
|
<div v-if="selectedPipeline" class="flex items-center gap-1">
|
|
285
278
|
<UBadge
|