@cat-factory/app 0.74.3 → 0.75.2

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.
@@ -7,8 +7,15 @@
7
7
  // run. The account scope has no resolved/merged catalog and fetches document
8
8
  // fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
9
9
  import { computed, ref, watch } from 'vue'
10
- import type { DocumentSourceKind, FragmentOwnerKind, ResolvedFragment } from '~/types/domain'
10
+ import type {
11
+ DocumentSourceKind,
12
+ FragmentOwnerKind,
13
+ GitHubAvailableRepo,
14
+ ResolvedFragment,
15
+ } from '~/types/domain'
11
16
  import { useFragmentLibrary, useFragmentLibraryStore } from '~/stores/fragmentLibrary'
17
+ import GitHubRepoSearchSelect from '~/components/github/GitHubRepoSearchSelect.vue'
18
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
12
19
 
13
20
  const props = withDefaults(
14
21
  defineProps<{
@@ -29,6 +36,7 @@ const library =
29
36
  ? useFragmentLibraryStore()
30
37
  : useFragmentLibrary(props.kind, props.ownerId)
31
38
  const documents = useDocumentsStore()
39
+ const github = useGitHubStore()
32
40
  const toast = useToast()
33
41
  const { t, d } = useI18n()
34
42
  const { confirm } = useConfirm()
@@ -50,10 +58,17 @@ watch(
50
58
  () => {
51
59
  void library.probe()
52
60
  void documents.probe()
61
+ // The GitHub pickers (repo search + tree browser) need the active board's
62
+ // installation state; probe once so they light up when the App is connected.
63
+ void github.probe()
53
64
  },
54
65
  { immediate: true },
55
66
  )
56
67
 
68
+ // The rich GitHub pickers reuse the active board's App installation. When it isn't
69
+ // connected (or the integration is off) both forms fall back to manual text entry.
70
+ const githubReady = computed(() => github.available === true && github.connected)
71
+
57
72
  type Tab = 'catalog' | 'authored' | 'documents' | 'sources'
58
73
  const tabs = computed<Tab[]>(() =>
59
74
  props.showCatalog
@@ -147,6 +162,40 @@ const docDraftValid = computed(
147
162
  () => !docLinkDisabled.value && docDraft.value.source && docDraft.value.ref.trim(),
148
163
  )
149
164
 
165
+ // ---- GitHub file picker (documents tab) -----------------------------------
166
+ // For a GitHub source, let the user search a repo + browse to the file instead of
167
+ // hand-typing a `owner/repo:path` ref. Picking a file fills `docDraft.ref` (still
168
+ // editable, so pasting a URL/shorthand keeps working). Only offered when the App is
169
+ // connected; other sources (Confluence/Notion) keep the free-text ref field.
170
+ const isGithubDoc = computed(() => docDraft.value.source === 'github')
171
+ const showGithubDocPicker = computed(() => isGithubDoc.value && githubReady.value)
172
+ const docRepoId = ref<number | undefined>(undefined)
173
+ const docRepo = ref<GitHubAvailableRepo | undefined>(undefined)
174
+ const docFilePath = ref<string | undefined>(undefined)
175
+
176
+ // Reset the picker (and the derived ref) whenever the selected source changes.
177
+ watch(
178
+ () => docDraft.value.source,
179
+ () => {
180
+ docRepoId.value = undefined
181
+ docRepo.value = undefined
182
+ docFilePath.value = undefined
183
+ docDraft.value.ref = ''
184
+ },
185
+ )
186
+
187
+ // A new repo selection clears the previously-browsed file.
188
+ watch(docRepoId, () => {
189
+ docFilePath.value = undefined
190
+ })
191
+
192
+ // Derive the canonical `owner/repo:path` ref the GitHub docs provider expects.
193
+ watch([docRepo, docFilePath], () => {
194
+ if (docRepo.value && docFilePath.value) {
195
+ docDraft.value.ref = `${docRepo.value.owner}/${docRepo.value.name}:${docFilePath.value}`
196
+ }
197
+ })
198
+
150
199
  /** This tier's existing document-backed fragments. */
151
200
  const documentFragments = computed(() => library.fragments.filter((f) => f.documentRef))
152
201
 
@@ -178,21 +227,50 @@ async function refreshFragment(id: string) {
178
227
  }
179
228
 
180
229
  // ---- repo sources ----------------------------------------------------------
181
- const sourceDraft = ref({ repoOwner: '', repoName: '', dirPath: '', gitRef: '' })
182
- const sourceValid = computed(
183
- () => sourceDraft.value.repoOwner.trim() && sourceDraft.value.repoName.trim(),
184
- )
230
+ // When the App is connected the user searches a repo + browses to the guideline
231
+ // directory; otherwise the manual owner/name/dir fields are the fallback.
232
+ const sourceRepoId = ref<number | undefined>(undefined)
233
+ const sourceRepo = ref<GitHubAvailableRepo | undefined>(undefined)
234
+ const sourceDir = ref<string | undefined>(undefined)
235
+ const sourceRef = ref('')
236
+ const manualSource = ref({ repoOwner: '', repoName: '', dirPath: '' })
237
+
238
+ // A new repo selection clears the previously-browsed directory.
239
+ watch(sourceRepoId, () => {
240
+ sourceDir.value = undefined
241
+ })
242
+
243
+ const sourceOwnerName = computed<{ owner: string; name: string } | null>(() => {
244
+ if (githubReady.value) {
245
+ return sourceRepo.value ? { owner: sourceRepo.value.owner, name: sourceRepo.value.name } : null
246
+ }
247
+ const owner = manualSource.value.repoOwner.trim()
248
+ const name = manualSource.value.repoName.trim()
249
+ return owner && name ? { owner, name } : null
250
+ })
251
+ const sourceValid = computed(() => sourceOwnerName.value !== null)
252
+
253
+ function resetSourceDraft() {
254
+ sourceRepoId.value = undefined
255
+ sourceRepo.value = undefined
256
+ sourceDir.value = undefined
257
+ sourceRef.value = ''
258
+ manualSource.value = { repoOwner: '', repoName: '', dirPath: '' }
259
+ }
185
260
 
186
261
  async function linkSource() {
187
- if (!sourceValid.value) return
262
+ const ownerName = sourceOwnerName.value
263
+ if (!ownerName) return
264
+ const dirPath =
265
+ (githubReady.value ? sourceDir.value : manualSource.value.dirPath.trim()) || undefined
188
266
  try {
189
267
  const source = await library.linkSource({
190
- repoOwner: sourceDraft.value.repoOwner.trim(),
191
- repoName: sourceDraft.value.repoName.trim(),
192
- dirPath: sourceDraft.value.dirPath.trim() || undefined,
193
- gitRef: sourceDraft.value.gitRef.trim() || undefined,
268
+ repoOwner: ownerName.owner,
269
+ repoName: ownerName.name,
270
+ dirPath,
271
+ gitRef: sourceRef.value.trim() || undefined,
194
272
  })
195
- sourceDraft.value = { repoOwner: '', repoName: '', dirPath: '', gitRef: '' }
273
+ resetSourceDraft()
196
274
  await library.syncSource(source.id)
197
275
  toast.add({ title: t('fragments.toast.sourceLinked'), icon: 'i-lucide-git-branch' })
198
276
  } catch (e) {
@@ -242,315 +320,364 @@ async function unlinkSource(id: string) {
242
320
 
243
321
  <template>
244
322
  <div class="flex flex-col gap-4">
245
- <p class="text-sm text-slate-400">
246
- <template v-if="isWorkspace">
247
- {{ t('fragments.intro.workspace') }}
248
- </template>
249
- <template v-else>
250
- {{ t('fragments.intro.account') }}
251
- </template>
252
- </p>
253
-
254
- <div class="flex gap-2">
255
- <UButton
256
- v-for="t in tabs"
257
- :key="t"
258
- :color="tab === t ? 'primary' : 'neutral'"
259
- :variant="tab === t ? 'solid' : 'ghost'"
260
- size="sm"
261
- @click="tab = t"
262
- >
263
- {{ tabLabel(t) }}
264
- </UButton>
323
+ <!-- The library is opt-out; if a deployment disabled it, don't offer forms that
324
+ would fail with a raw 503 — say so instead (any entry point lands here). -->
325
+ <div
326
+ v-if="library.available === false"
327
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-400"
328
+ >
329
+ {{ t('fragments.unavailable') }}
265
330
  </div>
266
331
 
267
- <!-- Resolved (merged) catalog — workspace scope only -->
268
- <div v-if="tab === 'catalog'" class="flex flex-col gap-2">
269
- <p class="text-xs text-slate-500">
270
- {{
271
- t(
272
- 'fragments.catalog.summary',
273
- { count: library.resolved.length, builtin: library.builtinCount },
274
- library.resolved.length,
275
- )
276
- }}
332
+ <template v-else>
333
+ <p class="text-sm text-slate-400">
334
+ <template v-if="isWorkspace">
335
+ {{ t('fragments.intro.workspace') }}
336
+ </template>
337
+ <template v-else>
338
+ {{ t('fragments.intro.account') }}
339
+ </template>
277
340
  </p>
278
- <div
279
- v-for="f in library.resolved"
280
- :key="f.id"
281
- class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
282
- >
283
- <div class="flex items-center gap-2">
284
- <span class="font-medium text-slate-100">{{ f.title }}</span>
285
- <UBadge size="xs" :color="tierColor[f.tier]" variant="subtle">
286
- {{ tierLabel[f.tier] }}
287
- </UBadge>
288
- <UBadge
289
- v-if="f.documentRef"
290
- size="xs"
291
- color="success"
292
- variant="subtle"
293
- icon="i-lucide-radio"
294
- >
295
- {{ t('fragments.catalog.live', { source: f.documentRef.source }) }}
296
- </UBadge>
297
- <span class="ms-auto font-mono text-[11px] text-slate-500">{{ f.id }}</span>
298
- </div>
299
- <p class="mt-1 text-sm text-slate-400">{{ f.summary }}</p>
300
- <div v-if="f.tags?.length" class="mt-1 flex flex-wrap gap-1">
301
- <UBadge v-for="tag in f.tags" :key="tag" size="xs" variant="outline" color="neutral">
302
- {{ tag }}
303
- </UBadge>
304
- </div>
305
- </div>
306
- </div>
307
341
 
308
- <!-- Hand-authored (this tier) -->
309
- <div v-else-if="tab === 'authored'" class="flex flex-col gap-3">
310
- <div
311
- v-for="f in library.fragments"
312
- :key="f.id"
313
- class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
314
- >
315
- <div class="min-w-0">
316
- <div class="flex items-center gap-2">
317
- <span class="font-medium text-slate-100">{{ f.title }}</span>
318
- <UBadge v-if="f.source" size="xs" color="info" variant="subtle">{{
319
- t('fragments.authored.fromRepo')
320
- }}</UBadge>
321
- </div>
322
- <p class="text-sm text-slate-400">{{ f.summary }}</p>
323
- </div>
342
+ <div class="flex gap-2">
324
343
  <UButton
325
- icon="i-lucide-trash-2"
326
- size="xs"
327
- color="error"
328
- variant="ghost"
329
- class="ms-auto"
330
- @click="removeFragment(f.id)"
331
- />
332
- </div>
333
- <p v-if="!library.fragments.length" class="text-sm text-slate-500">
334
- {{
335
- isWorkspace
336
- ? t('fragments.authored.empty.workspace')
337
- : t('fragments.authored.empty.account')
338
- }}
339
- </p>
340
-
341
- <div class="rounded-md border border-slate-800 p-3">
342
- <p class="mb-2 text-sm font-medium">{{ t('fragments.authored.addTitle') }}</p>
343
- <div class="flex flex-col gap-2">
344
- <UInput v-model="draft.title" :placeholder="t('fragments.authored.titlePlaceholder')" />
345
- <UInput
346
- v-model="draft.summary"
347
- :placeholder="t('fragments.authored.summaryPlaceholder')"
348
- />
349
- <UTextarea
350
- v-model="draft.body"
351
- :placeholder="t('fragments.authored.bodyPlaceholder')"
352
- :rows="4"
353
- />
354
- <UInput v-model="draft.tags" :placeholder="t('fragments.authored.tagsPlaceholder')" />
355
- <UButton
356
- icon="i-lucide-plus"
357
- size="sm"
358
- :disabled="!draftValid"
359
- :loading="library.loading"
360
- class="self-start"
361
- @click="createFragment"
362
- >
363
- {{ t('fragments.authored.add') }}
364
- </UButton>
365
- </div>
344
+ v-for="t in tabs"
345
+ :key="t"
346
+ :color="tab === t ? 'primary' : 'neutral'"
347
+ :variant="tab === t ? 'solid' : 'ghost'"
348
+ size="sm"
349
+ @click="tab = t"
350
+ >
351
+ {{ tabLabel(t) }}
352
+ </UButton>
366
353
  </div>
367
- </div>
368
354
 
369
- <!-- Document-backed (living) fragments -->
370
- <div v-else-if="tab === 'documents'" class="flex flex-col gap-3">
371
- <p class="text-xs text-slate-500">
372
- {{ t('fragments.documents.intro') }}
373
- </p>
374
-
375
- <div
376
- v-for="f in documentFragments"
377
- :key="f.id"
378
- class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
379
- >
380
- <UIcon name="i-lucide-radio" class="mt-0.5 h-4 w-4 text-emerald-400" />
381
- <div class="min-w-0">
355
+ <!-- Resolved (merged) catalog — workspace scope only -->
356
+ <div v-if="tab === 'catalog'" class="flex flex-col gap-2">
357
+ <p class="text-xs text-slate-500">
358
+ {{
359
+ t(
360
+ 'fragments.catalog.summary',
361
+ { count: library.resolved.length, builtin: library.builtinCount },
362
+ library.resolved.length,
363
+ )
364
+ }}
365
+ </p>
366
+ <div
367
+ v-for="f in library.resolved"
368
+ :key="f.id"
369
+ class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
370
+ >
382
371
  <div class="flex items-center gap-2">
383
372
  <span class="font-medium text-slate-100">{{ f.title }}</span>
384
- <UBadge size="xs" color="success" variant="subtle">
385
- {{ f.documentRef?.source }}
373
+ <UBadge size="xs" :color="tierColor[f.tier]" variant="subtle">
374
+ {{ tierLabel[f.tier] }}
375
+ </UBadge>
376
+ <UBadge
377
+ v-if="f.documentRef"
378
+ size="xs"
379
+ color="success"
380
+ variant="subtle"
381
+ icon="i-lucide-radio"
382
+ >
383
+ {{ t('fragments.catalog.live', { source: f.documentRef.source }) }}
384
+ </UBadge>
385
+ <span class="ms-auto font-mono text-[11px] text-slate-500">{{ f.id }}</span>
386
+ </div>
387
+ <p class="mt-1 text-sm text-slate-400">{{ f.summary }}</p>
388
+ <div v-if="f.tags?.length" class="mt-1 flex flex-wrap gap-1">
389
+ <UBadge v-for="tag in f.tags" :key="tag" size="xs" variant="outline" color="neutral">
390
+ {{ tag }}
386
391
  </UBadge>
387
392
  </div>
388
- <p class="text-sm text-slate-400">{{ f.summary }}</p>
389
- <p v-if="f.resolvedAt" class="text-[11px] text-slate-500">
390
- {{ t('fragments.documents.lastResolved', { date: d(new Date(f.resolvedAt), 'long') }) }}
391
- </p>
392
393
  </div>
393
- <div class="ms-auto flex gap-1">
394
- <UButton
395
- icon="i-lucide-refresh-cw"
396
- size="xs"
397
- variant="ghost"
398
- :loading="library.loading"
399
- :title="t('fragments.documents.refreshTitle')"
400
- @click="refreshFragment(f.id)"
401
- />
394
+ </div>
395
+
396
+ <!-- Hand-authored (this tier) -->
397
+ <div v-else-if="tab === 'authored'" class="flex flex-col gap-3">
398
+ <div
399
+ v-for="f in library.fragments"
400
+ :key="f.id"
401
+ class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
402
+ >
403
+ <div class="min-w-0">
404
+ <div class="flex items-center gap-2">
405
+ <span class="font-medium text-slate-100">{{ f.title }}</span>
406
+ <UBadge v-if="f.source" size="xs" color="info" variant="subtle">{{
407
+ t('fragments.authored.fromRepo')
408
+ }}</UBadge>
409
+ </div>
410
+ <p class="text-sm text-slate-400">{{ f.summary }}</p>
411
+ </div>
402
412
  <UButton
403
413
  icon="i-lucide-trash-2"
404
414
  size="xs"
405
415
  color="error"
406
416
  variant="ghost"
417
+ class="ms-auto"
407
418
  @click="removeFragment(f.id)"
408
419
  />
409
420
  </div>
410
- </div>
411
- <p v-if="!documentFragments.length" class="text-sm text-slate-500">
412
- {{ t('fragments.documents.empty') }}
413
- </p>
421
+ <p v-if="!library.fragments.length" class="text-sm text-slate-500">
422
+ {{
423
+ isWorkspace
424
+ ? t('fragments.authored.empty.workspace')
425
+ : t('fragments.authored.empty.account')
426
+ }}
427
+ </p>
414
428
 
415
- <div class="rounded-md border border-slate-800 p-3">
416
- <p class="mb-2 text-sm font-medium">{{ t('fragments.documents.linkTitle') }}</p>
417
- <div v-if="docLinkDisabled" class="text-sm text-slate-500">
418
- {{ t('fragments.documents.disabledHint') }}
419
- </div>
420
- <div v-else-if="!documents.connectedSources.length" class="text-sm text-slate-500">
421
- {{ t('fragments.documents.connectFirst') }}
422
- </div>
423
- <div v-else class="flex flex-col gap-2">
424
- <div class="flex flex-wrap gap-2">
429
+ <div class="rounded-md border border-slate-800 p-3">
430
+ <p class="mb-2 text-sm font-medium">{{ t('fragments.authored.addTitle') }}</p>
431
+ <div class="flex flex-col gap-2">
432
+ <UInput v-model="draft.title" :placeholder="t('fragments.authored.titlePlaceholder')" />
433
+ <UInput
434
+ v-model="draft.summary"
435
+ :placeholder="t('fragments.authored.summaryPlaceholder')"
436
+ />
437
+ <UTextarea
438
+ v-model="draft.body"
439
+ :placeholder="t('fragments.authored.bodyPlaceholder')"
440
+ :rows="4"
441
+ />
442
+ <UInput v-model="draft.tags" :placeholder="t('fragments.authored.tagsPlaceholder')" />
425
443
  <UButton
426
- v-for="s in documents.connectedSources"
427
- :key="s.source"
428
- size="xs"
429
- :color="docDraft.source === s.source ? 'primary' : 'neutral'"
430
- :variant="docDraft.source === s.source ? 'solid' : 'outline'"
431
- @click="docDraft.source = s.source"
444
+ icon="i-lucide-plus"
445
+ size="sm"
446
+ :disabled="!draftValid"
447
+ :loading="library.loading"
448
+ class="self-start"
449
+ @click="createFragment"
432
450
  >
433
- {{ s.label }}
451
+ {{ t('fragments.authored.add') }}
434
452
  </UButton>
435
453
  </div>
436
- <UInput v-model="docDraft.ref" :placeholder="t('fragments.documents.refPlaceholder')" />
437
- <UInput v-model="docDraft.tags" :placeholder="t('fragments.documents.tagsPlaceholder')" />
438
- <UButton
439
- icon="i-lucide-link"
440
- size="sm"
441
- :disabled="!docDraftValid"
442
- :loading="library.loading"
443
- class="self-start"
444
- @click="linkDocumentFragment"
445
- >
446
- {{ t('fragments.documents.link') }}
447
- </UButton>
448
454
  </div>
449
455
  </div>
450
- </div>
451
456
 
452
- <!-- Repo sources -->
453
- <div v-else class="flex flex-col gap-3">
454
- <div
455
- v-for="s in library.sources"
456
- :key="s.id"
457
- class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
458
- >
459
- <UIcon name="i-lucide-git-branch" class="h-4 w-4 text-slate-400" />
460
- <div class="min-w-0">
461
- <span class="font-mono text-sm text-slate-100">
462
- {{ s.repoOwner }}/{{ s.repoName
463
- }}<span class="text-slate-500">/{{ s.dirPath || '' }}</span>
464
- </span>
465
- <p class="text-xs text-slate-500">
466
- {{
467
- s.lastSyncedAt
468
- ? t('fragments.sources.metaSynced', { ref: s.gitRef })
469
- : t('fragments.sources.metaNever', { ref: s.gitRef })
470
- }}
471
- </p>
472
- </div>
473
- <UBadge
474
- v-if="library.sourceChanges[s.id]"
475
- size="xs"
476
- color="warning"
477
- variant="subtle"
478
- class="ms-auto"
479
- >
480
- {{
481
- t(
482
- 'fragments.sources.changes',
483
- { count: library.sourceChanges[s.id] },
484
- library.sourceChanges[s.id] ?? 0,
485
- )
486
- }}
487
- </UBadge>
488
- <div class="ms-auto flex gap-1">
489
- <UButton
490
- icon="i-lucide-search-check"
491
- size="xs"
492
- variant="ghost"
493
- @click="checkSource(s.id)"
494
- />
495
- <UButton
496
- icon="i-lucide-refresh-cw"
497
- size="xs"
498
- variant="ghost"
499
- :loading="library.loading"
500
- @click="syncSource(s.id)"
501
- />
502
- <UButton
503
- icon="i-lucide-unplug"
504
- size="xs"
505
- color="error"
506
- variant="ghost"
507
- @click="unlinkSource(s.id)"
508
- />
509
- </div>
510
- </div>
511
- <p v-if="!library.sources.length" class="text-sm text-slate-500">
512
- {{ t('fragments.sources.empty') }}
513
- </p>
457
+ <!-- Document-backed (living) fragments -->
458
+ <div v-else-if="tab === 'documents'" class="flex flex-col gap-3">
459
+ <p class="text-xs text-slate-500">
460
+ {{ t('fragments.documents.intro') }}
461
+ </p>
514
462
 
515
- <div class="rounded-md border border-slate-800 p-3">
516
- <p class="mb-2 text-sm font-medium">{{ t('fragments.sources.linkTitle') }}</p>
517
- <div class="flex flex-col gap-2">
518
- <div class="flex gap-2">
519
- <UInput
520
- v-model="sourceDraft.repoOwner"
521
- :placeholder="t('fragments.sources.ownerPlaceholder')"
522
- class="flex-1"
463
+ <div
464
+ v-for="f in documentFragments"
465
+ :key="f.id"
466
+ class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
467
+ >
468
+ <UIcon name="i-lucide-radio" class="mt-0.5 h-4 w-4 text-emerald-400" />
469
+ <div class="min-w-0">
470
+ <div class="flex items-center gap-2">
471
+ <span class="font-medium text-slate-100">{{ f.title }}</span>
472
+ <UBadge size="xs" color="success" variant="subtle">
473
+ {{ f.documentRef?.source }}
474
+ </UBadge>
475
+ </div>
476
+ <p class="text-sm text-slate-400">{{ f.summary }}</p>
477
+ <p v-if="f.resolvedAt" class="text-[11px] text-slate-500">
478
+ {{
479
+ t('fragments.documents.lastResolved', { date: d(new Date(f.resolvedAt), 'long') })
480
+ }}
481
+ </p>
482
+ </div>
483
+ <div class="ms-auto flex gap-1">
484
+ <UButton
485
+ icon="i-lucide-refresh-cw"
486
+ size="xs"
487
+ variant="ghost"
488
+ :loading="library.loading"
489
+ :title="t('fragments.documents.refreshTitle')"
490
+ @click="refreshFragment(f.id)"
523
491
  />
524
- <UInput
525
- v-model="sourceDraft.repoName"
526
- :placeholder="t('fragments.sources.repoPlaceholder')"
527
- class="flex-1"
492
+ <UButton
493
+ icon="i-lucide-trash-2"
494
+ size="xs"
495
+ color="error"
496
+ variant="ghost"
497
+ @click="removeFragment(f.id)"
528
498
  />
529
499
  </div>
530
- <div class="flex gap-2">
531
- <UInput
532
- v-model="sourceDraft.dirPath"
533
- :placeholder="t('fragments.sources.dirPlaceholder')"
534
- class="flex-1"
535
- />
500
+ </div>
501
+ <p v-if="!documentFragments.length" class="text-sm text-slate-500">
502
+ {{ t('fragments.documents.empty') }}
503
+ </p>
504
+
505
+ <div class="rounded-md border border-slate-800 p-3">
506
+ <p class="mb-2 text-sm font-medium">{{ t('fragments.documents.linkTitle') }}</p>
507
+ <div v-if="docLinkDisabled" class="text-sm text-slate-500">
508
+ {{ t('fragments.documents.disabledHint') }}
509
+ </div>
510
+ <div v-else-if="!documents.connectedSources.length" class="text-sm text-slate-500">
511
+ {{ t('fragments.documents.connectFirst') }}
512
+ </div>
513
+ <div v-else class="flex flex-col gap-2">
514
+ <div class="flex flex-wrap gap-2">
515
+ <UButton
516
+ v-for="s in documents.connectedSources"
517
+ :key="s.source"
518
+ size="xs"
519
+ :color="docDraft.source === s.source ? 'primary' : 'neutral'"
520
+ :variant="docDraft.source === s.source ? 'solid' : 'outline'"
521
+ @click="docDraft.source = s.source"
522
+ >
523
+ {{ s.label }}
524
+ </UButton>
525
+ </div>
526
+
527
+ <!-- GitHub: search a repo + browse to the file instead of typing the ref -->
528
+ <template v-if="showGithubDocPicker">
529
+ <GitHubRepoSearchSelect v-model="docRepoId" @update:repo="docRepo = $event" />
530
+ <div
531
+ v-if="docRepoId !== undefined"
532
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
533
+ >
534
+ <p class="mb-2 text-xs text-slate-400">
535
+ {{ t('fragments.documents.githubBrowseHint') }}
536
+ </p>
537
+ <RepoTreeBrowser v-model="docFilePath" :repo-github-id="docRepoId" mode="file" />
538
+ </div>
539
+ </template>
540
+
541
+ <UInput v-model="docDraft.ref" :placeholder="t('fragments.documents.refPlaceholder')" />
536
542
  <UInput
537
- v-model="sourceDraft.gitRef"
538
- :placeholder="t('fragments.sources.refPlaceholder')"
539
- class="flex-1"
543
+ v-model="docDraft.tags"
544
+ :placeholder="t('fragments.documents.tagsPlaceholder')"
540
545
  />
546
+ <UButton
547
+ icon="i-lucide-link"
548
+ size="sm"
549
+ :disabled="!docDraftValid"
550
+ :loading="library.loading"
551
+ class="self-start"
552
+ @click="linkDocumentFragment"
553
+ >
554
+ {{ t('fragments.documents.link') }}
555
+ </UButton>
541
556
  </div>
542
- <UButton
543
- icon="i-lucide-link"
544
- size="sm"
545
- :disabled="!sourceValid"
546
- :loading="library.loading"
547
- class="self-start"
548
- @click="linkSource"
557
+ </div>
558
+ </div>
559
+
560
+ <!-- Repo sources -->
561
+ <div v-else class="flex flex-col gap-3">
562
+ <div
563
+ v-for="s in library.sources"
564
+ :key="s.id"
565
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
566
+ >
567
+ <UIcon name="i-lucide-git-branch" class="h-4 w-4 text-slate-400" />
568
+ <div class="min-w-0">
569
+ <span class="font-mono text-sm text-slate-100">
570
+ {{ s.repoOwner }}/{{ s.repoName
571
+ }}<span class="text-slate-500">/{{ s.dirPath || '' }}</span>
572
+ </span>
573
+ <p class="text-xs text-slate-500">
574
+ {{
575
+ s.lastSyncedAt
576
+ ? t('fragments.sources.metaSynced', { ref: s.gitRef })
577
+ : t('fragments.sources.metaNever', { ref: s.gitRef })
578
+ }}
579
+ </p>
580
+ </div>
581
+ <UBadge
582
+ v-if="library.sourceChanges[s.id]"
583
+ size="xs"
584
+ color="warning"
585
+ variant="subtle"
586
+ class="ms-auto"
549
587
  >
550
- {{ t('fragments.sources.link') }}
551
- </UButton>
588
+ {{
589
+ t(
590
+ 'fragments.sources.changes',
591
+ { count: library.sourceChanges[s.id] },
592
+ library.sourceChanges[s.id] ?? 0,
593
+ )
594
+ }}
595
+ </UBadge>
596
+ <div class="ms-auto flex gap-1">
597
+ <UButton
598
+ icon="i-lucide-search-check"
599
+ size="xs"
600
+ variant="ghost"
601
+ @click="checkSource(s.id)"
602
+ />
603
+ <UButton
604
+ icon="i-lucide-refresh-cw"
605
+ size="xs"
606
+ variant="ghost"
607
+ :loading="library.loading"
608
+ @click="syncSource(s.id)"
609
+ />
610
+ <UButton
611
+ icon="i-lucide-unplug"
612
+ size="xs"
613
+ color="error"
614
+ variant="ghost"
615
+ @click="unlinkSource(s.id)"
616
+ />
617
+ </div>
618
+ </div>
619
+ <p v-if="!library.sources.length" class="text-sm text-slate-500">
620
+ {{ t('fragments.sources.empty') }}
621
+ </p>
622
+
623
+ <div class="rounded-md border border-slate-800 p-3">
624
+ <p class="mb-2 text-sm font-medium">{{ t('fragments.sources.linkTitle') }}</p>
625
+ <div class="flex flex-col gap-2">
626
+ <!-- Connected: search a repo + browse to the guideline directory -->
627
+ <template v-if="githubReady">
628
+ <GitHubRepoSearchSelect v-model="sourceRepoId" @update:repo="sourceRepo = $event" />
629
+ <div
630
+ v-if="sourceRepoId !== undefined"
631
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-2"
632
+ >
633
+ <p class="mb-2 text-xs text-slate-400">
634
+ {{ t('fragments.sources.browseHint') }}
635
+ </p>
636
+ <RepoTreeBrowser v-model="sourceDir" :repo-github-id="sourceRepoId" mode="dir" />
637
+ <p class="mt-2 truncate text-xs text-slate-400">
638
+ <template v-if="sourceDir">
639
+ {{ t('fragments.sources.selectedDir') }}
640
+ <code class="text-slate-200">{{ sourceDir }}</code>
641
+ </template>
642
+ <template v-else>{{ t('fragments.sources.wholeRepo') }}</template>
643
+ </p>
644
+ </div>
645
+ </template>
646
+
647
+ <!-- Not connected: manual owner/name/dir fallback -->
648
+ <template v-else>
649
+ <div class="flex gap-2">
650
+ <UInput
651
+ v-model="manualSource.repoOwner"
652
+ :placeholder="t('fragments.sources.ownerPlaceholder')"
653
+ class="flex-1"
654
+ />
655
+ <UInput
656
+ v-model="manualSource.repoName"
657
+ :placeholder="t('fragments.sources.repoPlaceholder')"
658
+ class="flex-1"
659
+ />
660
+ </div>
661
+ <UInput
662
+ v-model="manualSource.dirPath"
663
+ :placeholder="t('fragments.sources.dirPlaceholder')"
664
+ />
665
+ </template>
666
+
667
+ <UInput v-model="sourceRef" :placeholder="t('fragments.sources.refPlaceholder')" />
668
+ <UButton
669
+ icon="i-lucide-link"
670
+ size="sm"
671
+ :disabled="!sourceValid"
672
+ :loading="library.loading"
673
+ class="self-start"
674
+ @click="linkSource"
675
+ >
676
+ {{ t('fragments.sources.link') }}
677
+ </UButton>
678
+ </div>
552
679
  </div>
553
680
  </div>
554
- </div>
681
+ </template>
555
682
  </div>
556
683
  </template>