@cat-factory/app 0.116.6 → 0.116.8
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/auth/AuthGate.vue +3 -1
- package/app/components/github/AddServiceFromRepoModal.vue +167 -38
- package/app/components/github/RepoTreeBrowser.vue +56 -14
- package/app/components/layout/SideBar.vue +8 -5
- package/app/composables/useSingleFlightProbe.spec.ts +114 -0
- package/app/composables/useSingleFlightProbe.ts +70 -0
- package/app/composables/useSourceIntegration.ts +17 -1
- package/app/pages/index.vue +7 -3
- package/app/stores/documents.ts +3 -1
- package/app/stores/fragmentLibrary.ts +7 -1
- package/app/stores/github.ts +8 -1
- package/app/stores/slack.ts +7 -1
- package/app/stores/tasks.ts +3 -1
- package/app/stores/workspace.spec.ts +67 -0
- package/app/stores/workspace.ts +30 -5
- package/app/utils/bootMarks.ts +49 -0
- package/app/utils/repoPath.ts +10 -0
- package/i18n/locales/de.json +9 -5
- package/i18n/locales/en.json +15 -5
- package/i18n/locales/es.json +9 -5
- package/i18n/locales/fr.json +9 -5
- package/i18n/locales/he.json +9 -5
- package/i18n/locales/it.json +9 -5
- package/i18n/locales/ja.json +9 -5
- package/i18n/locales/pl.json +9 -5
- package/i18n/locales/tr.json +9 -5
- package/i18n/locales/uk.json +9 -5
- package/package.json +1 -1
|
@@ -14,7 +14,9 @@ const { t } = useI18n()
|
|
|
14
14
|
// out, so it must render even when auth is required and there's no user.
|
|
15
15
|
const isPublicRoute = computed(() => route.path === '/reset-password')
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Stamp the first cold-open milestone once the auth handshake settles (app-startup initiative,
|
|
18
|
+
// item 1) — bootstrap resolves even on failure (it catches internally), so `finally` always fires.
|
|
19
|
+
onMounted(() => void auth.bootstrap().finally(() => markBoot('auth-ready')))
|
|
18
20
|
</script>
|
|
19
21
|
|
|
20
22
|
<template>
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
//
|
|
9
9
|
// MONOREPO support: a repo flagged a monorepo can back SEVERAL services, each
|
|
10
10
|
// pinned to a subdirectory. When the selected repo is a monorepo, the user
|
|
11
|
-
// browses its tree and
|
|
12
|
-
//
|
|
11
|
+
// browses its tree and multi-selects the service directories to add — from ANY
|
|
12
|
+
// parent folder, in one pass — then adds them all at once. Directories that
|
|
13
|
+
// already back a service on this board are shown but not selectable.
|
|
13
14
|
import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
|
|
14
15
|
import GitHubConnect from '~/components/github/GitHubConnect.vue'
|
|
15
16
|
import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
|
|
@@ -136,11 +137,39 @@ const repoMenuItems = computed(() => {
|
|
|
136
137
|
// repo + requires a directory when it creates the service). A repo already flagged a
|
|
137
138
|
// monorepo (it backs other services) seeds the toggle on when selected.
|
|
138
139
|
const isMonorepo = ref(false)
|
|
139
|
-
|
|
140
|
+
// The cart of monorepo service directories the user has picked (repo-root-relative),
|
|
141
|
+
// accumulated across the whole browse session so picks from different parent folders
|
|
142
|
+
// coexist. Added all at once (see `addServices`), unlike the one-at-a-time whole-repo add.
|
|
143
|
+
const selectedDirectories = ref<string[]>([])
|
|
144
|
+
|
|
145
|
+
// Directories in the selected repo that ALREADY back a service on this board — surfaced
|
|
146
|
+
// so the tree browser can disable them (adding one again would be a no-op). Derived from
|
|
147
|
+
// the org catalog filtered to this repo; a whole-repo service (null directory) is ignored.
|
|
148
|
+
const addedDirectories = computed<string[]>(() => {
|
|
149
|
+
if (selectedRepoId.value === undefined) return []
|
|
150
|
+
return services.catalog
|
|
151
|
+
.filter((s) => s.repoGithubId === selectedRepoId.value && s.directory)
|
|
152
|
+
.map((s) => normalizeRepoPath(s.directory as string))
|
|
153
|
+
})
|
|
154
|
+
const addedDirSet = computed(() => new Set(addedDirectories.value))
|
|
140
155
|
|
|
141
156
|
function toggleMonorepo(value: boolean) {
|
|
142
157
|
isMonorepo.value = value
|
|
143
|
-
|
|
158
|
+
selectedDirectories.value = []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Add/remove a directory from the cart. Guards against an already-added directory (the
|
|
162
|
+
// browser disables it, but keep the model authoritative).
|
|
163
|
+
function toggleDirectory(path: string) {
|
|
164
|
+
if (addedDirSet.value.has(normalizeRepoPath(path))) return
|
|
165
|
+
const i = selectedDirectories.value.indexOf(path)
|
|
166
|
+
if (i >= 0) selectedDirectories.value.splice(i, 1)
|
|
167
|
+
else selectedDirectories.value.push(path)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function removeSelected(path: string) {
|
|
171
|
+
const i = selectedDirectories.value.indexOf(path)
|
|
172
|
+
if (i >= 0) selectedDirectories.value.splice(i, 1)
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
// On repo change, capture the picked repo (from the volatile loaded list, before a later
|
|
@@ -152,13 +181,13 @@ watch(selectedRepoId, (id) => {
|
|
|
152
181
|
if (found) selectedRepo.value = found
|
|
153
182
|
}
|
|
154
183
|
isMonorepo.value = selectedRepo.value?.isMonorepo === true
|
|
155
|
-
|
|
184
|
+
selectedDirectories.value = []
|
|
156
185
|
configuredBlockId.value = undefined
|
|
157
186
|
})
|
|
158
187
|
|
|
159
188
|
function resetSelection() {
|
|
160
189
|
selectedRepoId.value = undefined
|
|
161
|
-
|
|
190
|
+
selectedDirectories.value = []
|
|
162
191
|
isMonorepo.value = false
|
|
163
192
|
configuredBlockId.value = undefined
|
|
164
193
|
resetRepoSearch()
|
|
@@ -186,12 +215,11 @@ function openManageInstall() {
|
|
|
186
215
|
if (manageInstallUrl.value) window.open(manageInstallUrl.value, '_blank', 'noopener')
|
|
187
216
|
}
|
|
188
217
|
|
|
189
|
-
// The just-added service, kept on the board store so the user can configure it
|
|
190
|
-
// infra + fragments) right here — the same controls as the inspector.
|
|
191
|
-
//
|
|
192
|
-
//
|
|
218
|
+
// The just-added whole-repo service, kept on the board store so the user can configure it
|
|
219
|
+
// (test infra + fragments) right here — the same controls as the inspector. Only the
|
|
220
|
+
// whole-repo flow surfaces this inline configure step; a monorepo adds several services at
|
|
221
|
+
// once and they're configured later in the inspector.
|
|
193
222
|
const configuredBlockId = ref<string | undefined>(undefined)
|
|
194
|
-
const configuredDirectory = ref<string | undefined>(undefined)
|
|
195
223
|
const configuredBlock = computed(() =>
|
|
196
224
|
configuredBlockId.value ? board.getBlock(configuredBlockId.value) : undefined,
|
|
197
225
|
)
|
|
@@ -210,12 +238,21 @@ watch(
|
|
|
210
238
|
{ immediate: true },
|
|
211
239
|
)
|
|
212
240
|
|
|
213
|
-
// A
|
|
241
|
+
// A whole-repo service is added once (then configured inline). A monorepo instead
|
|
242
|
+
// multi-selects directories and adds them together via `addServices`.
|
|
214
243
|
const canAdd = computed(
|
|
215
244
|
() =>
|
|
216
245
|
!needsGitHub.value &&
|
|
217
246
|
selectedRepoId.value !== undefined &&
|
|
218
|
-
|
|
247
|
+
!isMonorepo.value &&
|
|
248
|
+
!configuredBlockId.value,
|
|
249
|
+
)
|
|
250
|
+
const canAddServices = computed(
|
|
251
|
+
() =>
|
|
252
|
+
!needsGitHub.value &&
|
|
253
|
+
selectedRepoId.value !== undefined &&
|
|
254
|
+
isMonorepo.value &&
|
|
255
|
+
selectedDirectories.value.length > 0,
|
|
219
256
|
)
|
|
220
257
|
|
|
221
258
|
async function add() {
|
|
@@ -223,8 +260,10 @@ async function add() {
|
|
|
223
260
|
adding.value = true
|
|
224
261
|
try {
|
|
225
262
|
const block = await board.addServiceFromRepo(selectedRepoId.value, {
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
// The switch is off, so import the whole repo as ONE service. Send the flag
|
|
264
|
+
// explicitly: a repo already flagged a monorepo (the toggle seeds on) must be
|
|
265
|
+
// un-flagged here, or the backend still requires a service subdirectory and rejects.
|
|
266
|
+
isMonorepo: false,
|
|
228
267
|
type: selectedType.value,
|
|
229
268
|
// Place the imported frame in free space (centred in view) instead of the
|
|
230
269
|
// backend's default stagger, so it never overlaps an existing service.
|
|
@@ -235,15 +274,57 @@ async function add() {
|
|
|
235
274
|
// Centre the camera on the newly imported service.
|
|
236
275
|
await focusFrame(block.id)
|
|
237
276
|
configuredBlockId.value = block.id
|
|
238
|
-
configuredDirectory.value = isMonorepo.value ? selectedDirectory.value : undefined
|
|
239
277
|
toast.add({
|
|
240
278
|
title: t('github.addService.toast.addedTitle'),
|
|
241
279
|
description: t('github.addService.toast.addedDescription', { title: block.title }),
|
|
242
280
|
icon: 'i-lucide-check',
|
|
243
281
|
color: 'success',
|
|
244
282
|
})
|
|
245
|
-
|
|
246
|
-
|
|
283
|
+
} catch (e) {
|
|
284
|
+
toast.add({
|
|
285
|
+
title: t('github.addService.toast.addFailedTitle'),
|
|
286
|
+
description: e instanceof Error ? e.message : String(e),
|
|
287
|
+
icon: 'i-lucide-triangle-alert',
|
|
288
|
+
color: 'error',
|
|
289
|
+
})
|
|
290
|
+
} finally {
|
|
291
|
+
adding.value = false
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Add every directory in the cart as its own service, in one action. Each add lays the
|
|
296
|
+
// frame out in free space (seeing the ones added earlier in the loop, so they don't
|
|
297
|
+
// overlap); the projection is refreshed and the camera centres on the last one. The
|
|
298
|
+
// just-added directories then move to `addedDirectories`, so the cart is cleared and the
|
|
299
|
+
// tree marks them "added" — ready to pick more (from any folder) or close.
|
|
300
|
+
async function addServices() {
|
|
301
|
+
if (!canAddServices.value || selectedRepoId.value === undefined) return
|
|
302
|
+
const dirs = selectedDirectories.value.filter((d) => !addedDirSet.value.has(normalizeRepoPath(d)))
|
|
303
|
+
if (dirs.length === 0) return
|
|
304
|
+
adding.value = true
|
|
305
|
+
try {
|
|
306
|
+
let lastBlock: Awaited<ReturnType<typeof board.addServiceFromRepo>> | undefined
|
|
307
|
+
for (const directory of dirs) {
|
|
308
|
+
lastBlock = await board.addServiceFromRepo(selectedRepoId.value, {
|
|
309
|
+
directory,
|
|
310
|
+
isMonorepo: true,
|
|
311
|
+
type: selectedType.value,
|
|
312
|
+
position: freeFramePosition(),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
await github.load()
|
|
316
|
+
if (lastBlock) await focusFrame(lastBlock.id)
|
|
317
|
+
selectedDirectories.value = []
|
|
318
|
+
toast.add({
|
|
319
|
+
title: t('github.addService.toast.servicesAddedTitle'),
|
|
320
|
+
description: t(
|
|
321
|
+
'github.addService.toast.servicesAddedDescription',
|
|
322
|
+
{ count: dirs.length },
|
|
323
|
+
dirs.length,
|
|
324
|
+
),
|
|
325
|
+
icon: 'i-lucide-check',
|
|
326
|
+
color: 'success',
|
|
327
|
+
})
|
|
247
328
|
} catch (e) {
|
|
248
329
|
toast.add({
|
|
249
330
|
title: t('github.addService.toast.addFailedTitle'),
|
|
@@ -329,8 +410,9 @@ function done() {
|
|
|
329
410
|
<USelect v-model="selectedType" :items="typeItems" value-key="value" class="w-full" />
|
|
330
411
|
</UFormField>
|
|
331
412
|
|
|
332
|
-
<!-- monorepo handling: flag + directory picker
|
|
333
|
-
|
|
413
|
+
<!-- monorepo handling: flag + multi-directory picker (hidden once a whole-repo
|
|
414
|
+
service has been added and is being configured inline) -->
|
|
415
|
+
<div v-if="selectedRepoId !== undefined && !configuredBlock" class="space-y-3">
|
|
334
416
|
<USwitch
|
|
335
417
|
:model-value="isMonorepo"
|
|
336
418
|
:label="t('github.addService.monorepoLabel')"
|
|
@@ -340,23 +422,65 @@ function done() {
|
|
|
340
422
|
|
|
341
423
|
<div
|
|
342
424
|
v-if="isMonorepo"
|
|
343
|
-
class="rounded-md border border-slate-700/60 bg-slate-900/40 p-3"
|
|
425
|
+
class="space-y-3 rounded-md border border-slate-700/60 bg-slate-900/40 p-3"
|
|
344
426
|
>
|
|
345
|
-
<p class="
|
|
427
|
+
<p class="text-xs text-slate-400">
|
|
346
428
|
{{ t('github.addService.monorepoBrowseHint') }}
|
|
347
429
|
</p>
|
|
348
430
|
<RepoTreeBrowser
|
|
349
|
-
v-model="selectedDirectory"
|
|
350
431
|
:repo-github-id="selectedRepoId!"
|
|
351
432
|
mode="dir"
|
|
433
|
+
multiple
|
|
434
|
+
:selected-paths="selectedDirectories"
|
|
435
|
+
:added-paths="addedDirectories"
|
|
436
|
+
@toggle="toggleDirectory"
|
|
352
437
|
/>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
438
|
+
|
|
439
|
+
<!-- the selection cart + the add action sit right beside the tree, so the
|
|
440
|
+
picked services and the button that adds them are never scrolled apart -->
|
|
441
|
+
<div class="space-y-2 rounded-md border border-slate-800 bg-slate-950/40 p-2.5">
|
|
442
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
443
|
+
{{ t('github.addService.selectedServices') }}
|
|
444
|
+
</p>
|
|
445
|
+
<div v-if="selectedDirectories.length" class="flex flex-wrap gap-1.5">
|
|
446
|
+
<span
|
|
447
|
+
v-for="dir in selectedDirectories"
|
|
448
|
+
:key="dir"
|
|
449
|
+
class="inline-flex items-center gap-1 rounded bg-slate-800 px-2 py-0.5 text-xs text-slate-200"
|
|
450
|
+
>
|
|
451
|
+
<code class="text-slate-200">{{ dir }}</code>
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
class="text-slate-400 hover:text-slate-100"
|
|
455
|
+
:aria-label="t('github.addService.removeService', { directory: dir })"
|
|
456
|
+
@click="removeSelected(dir)"
|
|
457
|
+
>
|
|
458
|
+
<UIcon name="i-lucide-x" class="h-3 w-3" />
|
|
459
|
+
</button>
|
|
460
|
+
</span>
|
|
461
|
+
</div>
|
|
462
|
+
<p v-else class="text-xs text-slate-500">
|
|
463
|
+
{{ t('github.addService.noServicesSelected') }}
|
|
464
|
+
</p>
|
|
465
|
+
<div class="flex justify-end">
|
|
466
|
+
<UButton
|
|
467
|
+
color="primary"
|
|
468
|
+
icon="i-lucide-plus"
|
|
469
|
+
size="sm"
|
|
470
|
+
:loading="adding"
|
|
471
|
+
:disabled="!canAddServices"
|
|
472
|
+
@click="addServices"
|
|
473
|
+
>
|
|
474
|
+
{{
|
|
475
|
+
t(
|
|
476
|
+
'github.addService.addServices',
|
|
477
|
+
{ count: selectedDirectories.length },
|
|
478
|
+
selectedDirectories.length,
|
|
479
|
+
)
|
|
480
|
+
}}
|
|
481
|
+
</UButton>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
360
484
|
</div>
|
|
361
485
|
</div>
|
|
362
486
|
|
|
@@ -373,7 +497,7 @@ function done() {
|
|
|
373
497
|
</div>
|
|
374
498
|
<ServiceTestConfig
|
|
375
499
|
:block="configuredBlock"
|
|
376
|
-
:repo="{ githubId: selectedRepoId
|
|
500
|
+
:repo="{ githubId: selectedRepoId! }"
|
|
377
501
|
default-open
|
|
378
502
|
/>
|
|
379
503
|
<ServiceFragments :block="configuredBlock" default-open />
|
|
@@ -395,22 +519,27 @@ function done() {
|
|
|
395
519
|
</div>
|
|
396
520
|
|
|
397
521
|
<div class="flex justify-end gap-2">
|
|
398
|
-
|
|
522
|
+
<!-- Monorepo adds via the cart's own button; the footer only closes. A
|
|
523
|
+
whole-repo add shows its "Add service" button until one is added, then
|
|
524
|
+
the inline configure panel + this Done. -->
|
|
525
|
+
<UButton
|
|
526
|
+
v-if="configuredBlock || isMonorepo"
|
|
527
|
+
color="neutral"
|
|
528
|
+
variant="soft"
|
|
529
|
+
size="sm"
|
|
530
|
+
@click="done"
|
|
531
|
+
>
|
|
399
532
|
{{ t('github.addService.done') }}
|
|
400
533
|
</UButton>
|
|
401
534
|
<UButton
|
|
402
|
-
v-if="!
|
|
535
|
+
v-if="!isMonorepo && !configuredBlock"
|
|
403
536
|
color="primary"
|
|
404
537
|
icon="i-lucide-plus"
|
|
405
538
|
:loading="adding"
|
|
406
539
|
:disabled="!canAdd"
|
|
407
540
|
@click="add"
|
|
408
541
|
>
|
|
409
|
-
{{
|
|
410
|
-
configuredBlock && isMonorepo
|
|
411
|
-
? t('github.addService.addAnother')
|
|
412
|
-
: t('github.addService.add')
|
|
413
|
-
}}
|
|
542
|
+
{{ t('github.addService.add') }}
|
|
414
543
|
</UButton>
|
|
415
544
|
</div>
|
|
416
545
|
</template>
|
|
@@ -6,20 +6,37 @@
|
|
|
6
6
|
// The selected path (relative to the repo root, as GitHub returns it) is exposed
|
|
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
|
+
//
|
|
10
|
+
// `dir` mode additionally supports `multiple`: instead of the single `v-model`
|
|
11
|
+
// path, the caller passes the current `selectedPaths` (a cart) + `addedPaths`
|
|
12
|
+
// (directories already on the board, shown disabled) and handles the `toggle`
|
|
13
|
+
// event to add/remove a directory. This lets one browse session accumulate
|
|
14
|
+
// several services from ANY parent folder (the monorepo add flow) — navigating
|
|
15
|
+
// away never drops earlier picks.
|
|
9
16
|
import type { RepoTreeEntry } from '~/types/domain'
|
|
10
17
|
|
|
11
18
|
const props = withDefaults(
|
|
12
19
|
defineProps<{
|
|
13
20
|
repoGithubId: number
|
|
14
21
|
mode?: 'dir' | 'file'
|
|
15
|
-
/** Currently picked path (repo-root-relative), via v-model. */
|
|
22
|
+
/** Currently picked path (repo-root-relative), via v-model. Single-select only. */
|
|
16
23
|
modelValue?: string
|
|
17
24
|
/** Directory to open at (e.g. a monorepo service's subdirectory). */
|
|
18
25
|
startPath?: string
|
|
26
|
+
/** `dir` mode: accumulate a set of picks (via `selectedPaths`/`toggle`) instead of one. */
|
|
27
|
+
multiple?: boolean
|
|
28
|
+
/** `dir` + `multiple`: the current cart of picked directories (repo-root-relative). */
|
|
29
|
+
selectedPaths?: string[]
|
|
30
|
+
/** `dir` + `multiple`: directories already on the board — listed but not selectable. */
|
|
31
|
+
addedPaths?: string[]
|
|
19
32
|
}>(),
|
|
20
|
-
{ mode: 'dir', startPath: '' },
|
|
33
|
+
{ mode: 'dir', startPath: '', multiple: false, selectedPaths: () => [], addedPaths: () => [] },
|
|
21
34
|
)
|
|
22
|
-
const emit = defineEmits<{
|
|
35
|
+
const emit = defineEmits<{
|
|
36
|
+
'update:modelValue': [string | undefined]
|
|
37
|
+
/** `dir` + `multiple`: the user asked to add/remove this directory from the cart. */
|
|
38
|
+
toggle: [string]
|
|
39
|
+
}>()
|
|
23
40
|
|
|
24
41
|
const { t } = useI18n()
|
|
25
42
|
const github = useGitHubStore()
|
|
@@ -29,6 +46,15 @@ const currentPath = ref(props.startPath)
|
|
|
29
46
|
const treeEntries = ref<RepoTreeEntry[]>([])
|
|
30
47
|
const loading = ref(false)
|
|
31
48
|
|
|
49
|
+
const selectedSet = computed(() => new Set(props.selectedPaths.map(normalizeRepoPath)))
|
|
50
|
+
const addedSet = computed(() => new Set(props.addedPaths.map(normalizeRepoPath)))
|
|
51
|
+
function isAdded(path: string): boolean {
|
|
52
|
+
return props.multiple && addedSet.value.has(normalizeRepoPath(path))
|
|
53
|
+
}
|
|
54
|
+
function isPicked(path: string): boolean {
|
|
55
|
+
return props.multiple ? selectedSet.value.has(normalizeRepoPath(path)) : props.modelValue === path
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
const dirEntries = computed(() => treeEntries.value.filter((e) => e.type === 'dir'))
|
|
33
59
|
const fileEntries = computed(() => treeEntries.value.filter((e) => e.type === 'file'))
|
|
34
60
|
const isEmpty = computed(() =>
|
|
@@ -63,7 +89,13 @@ async function browseTo(path: string) {
|
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
function pick(path: string) {
|
|
66
|
-
|
|
92
|
+
if (props.multiple) {
|
|
93
|
+
// Already-on-board directories are shown for orientation but can't be re-added.
|
|
94
|
+
if (addedSet.value.has(normalizeRepoPath(path))) return
|
|
95
|
+
emit('toggle', path)
|
|
96
|
+
} else {
|
|
97
|
+
emit('update:modelValue', path)
|
|
98
|
+
}
|
|
67
99
|
}
|
|
68
100
|
|
|
69
101
|
// Re-open at the start path whenever the repo (or requested root) changes.
|
|
@@ -124,18 +156,21 @@ watch(
|
|
|
124
156
|
<UIcon name="i-lucide-folder" class="h-4 w-4 shrink-0 text-amber-400" />
|
|
125
157
|
<span class="truncate">{{ entry.name }}</span>
|
|
126
158
|
</button>
|
|
159
|
+
<span
|
|
160
|
+
v-if="mode === 'dir' && isAdded(entry.path)"
|
|
161
|
+
class="flex shrink-0 items-center gap-1 text-xs text-slate-500"
|
|
162
|
+
>
|
|
163
|
+
<UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
|
|
164
|
+
{{ t('github.repoTree.added') }}
|
|
165
|
+
</span>
|
|
127
166
|
<UButton
|
|
128
|
-
v-if="mode === 'dir'"
|
|
167
|
+
v-else-if="mode === 'dir'"
|
|
129
168
|
size="xs"
|
|
130
169
|
variant="soft"
|
|
131
|
-
:color="
|
|
170
|
+
:color="isPicked(entry.path) ? 'primary' : 'neutral'"
|
|
132
171
|
@click="pick(entry.path)"
|
|
133
172
|
>
|
|
134
|
-
{{
|
|
135
|
-
modelValue === entry.path
|
|
136
|
-
? t('github.repoTree.selected')
|
|
137
|
-
: t('github.repoTree.select')
|
|
138
|
-
}}
|
|
173
|
+
{{ isPicked(entry.path) ? t('github.repoTree.selected') : t('github.repoTree.select') }}
|
|
139
174
|
</UButton>
|
|
140
175
|
</li>
|
|
141
176
|
<template v-if="mode === 'file'">
|
|
@@ -164,14 +199,21 @@ watch(
|
|
|
164
199
|
</div>
|
|
165
200
|
|
|
166
201
|
<!-- dir mode: pin the current folder without descending into a child -->
|
|
167
|
-
<div
|
|
202
|
+
<div
|
|
203
|
+
v-if="mode === 'dir' && currentPath && !isAdded(currentPath)"
|
|
204
|
+
class="mt-2 flex justify-end"
|
|
205
|
+
>
|
|
168
206
|
<UButton
|
|
169
207
|
size="xs"
|
|
170
208
|
variant="soft"
|
|
171
|
-
:color="
|
|
209
|
+
:color="isPicked(currentPath) ? 'primary' : 'neutral'"
|
|
172
210
|
@click="pick(currentPath)"
|
|
173
211
|
>
|
|
174
|
-
{{
|
|
212
|
+
{{
|
|
213
|
+
multiple && isPicked(currentPath)
|
|
214
|
+
? t('github.repoTree.selected')
|
|
215
|
+
: t('github.repoTree.useThisFolder')
|
|
216
|
+
}}
|
|
175
217
|
</UButton>
|
|
176
218
|
</div>
|
|
177
219
|
</div>
|
|
@@ -100,11 +100,14 @@ watch(
|
|
|
100
100
|
() => workspace.workspaceId,
|
|
101
101
|
(id) => {
|
|
102
102
|
if (!id) return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
void
|
|
107
|
-
void
|
|
103
|
+
// `ensureProbed` single-flights per board (app-startup initiative, item 12): on a cold open
|
|
104
|
+
// these coalesce with the board page's own github probe and don't refire on a re-mount, while a
|
|
105
|
+
// workspace switch (new id) still re-probes. `probe()` stays the explicit post-connect refresh.
|
|
106
|
+
void documents.ensureProbed()
|
|
107
|
+
void tasks.ensureProbed()
|
|
108
|
+
void github.ensureProbed()
|
|
109
|
+
void slack.ensureProbed()
|
|
110
|
+
void library.ensureProbed()
|
|
108
111
|
void providerConnections.ensureLoaded().catch(() => {})
|
|
109
112
|
},
|
|
110
113
|
{ immediate: true },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
3
|
+
|
|
4
|
+
// Single-flight probe guard (app-startup initiative, item 12). Pure logic, no Pinia/Nuxt: a fake
|
|
5
|
+
// `run` (counting calls, with a manually-resolved promise) and a mutable `id` getter.
|
|
6
|
+
|
|
7
|
+
/** A `run` whose promise the test resolves by hand, plus a call counter. */
|
|
8
|
+
function deferredRun() {
|
|
9
|
+
let resolve!: () => void
|
|
10
|
+
const calls = { count: 0 }
|
|
11
|
+
const run = vi.fn(() => {
|
|
12
|
+
calls.count++
|
|
13
|
+
return new Promise<void>((r) => (resolve = r))
|
|
14
|
+
})
|
|
15
|
+
return { run, calls, resolve: () => resolve() }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('useSingleFlightProbe', () => {
|
|
19
|
+
it('coalesces concurrent probe() calls for the same board into one run', async () => {
|
|
20
|
+
const { run, calls, resolve } = deferredRun()
|
|
21
|
+
const { probe } = useSingleFlightProbe(run, () => 'ws1')
|
|
22
|
+
|
|
23
|
+
const a = probe()
|
|
24
|
+
const b = probe()
|
|
25
|
+
expect(calls.count).toBe(1) // one in-flight run shared by both callers
|
|
26
|
+
resolve()
|
|
27
|
+
await Promise.all([a, b])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('ensureProbed() is a no-op once the board is already probed', async () => {
|
|
31
|
+
const { run, calls, resolve } = deferredRun()
|
|
32
|
+
const { ensureProbed } = useSingleFlightProbe(run, () => 'ws1')
|
|
33
|
+
|
|
34
|
+
const first = ensureProbed()
|
|
35
|
+
resolve()
|
|
36
|
+
await first
|
|
37
|
+
expect(calls.count).toBe(1)
|
|
38
|
+
|
|
39
|
+
await ensureProbed() // already settled for ws1 → does not run again
|
|
40
|
+
expect(calls.count).toBe(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('ensureProbed() re-runs when the workspace id changes', async () => {
|
|
44
|
+
const { run, calls, resolve } = deferredRun()
|
|
45
|
+
let id = 'ws1'
|
|
46
|
+
const { ensureProbed } = useSingleFlightProbe(run, () => id)
|
|
47
|
+
|
|
48
|
+
const first = ensureProbed()
|
|
49
|
+
resolve()
|
|
50
|
+
await first
|
|
51
|
+
expect(calls.count).toBe(1)
|
|
52
|
+
|
|
53
|
+
id = 'ws2' // a switch — connections are per board, so it must re-probe
|
|
54
|
+
const second = ensureProbed()
|
|
55
|
+
resolve()
|
|
56
|
+
await second
|
|
57
|
+
expect(calls.count).toBe(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('probe() always re-runs (a deliberate refresh) even after a completed probe', async () => {
|
|
61
|
+
const { run, calls, resolve } = deferredRun()
|
|
62
|
+
const guard = useSingleFlightProbe(run, () => 'ws1')
|
|
63
|
+
|
|
64
|
+
const first = guard.probe()
|
|
65
|
+
resolve()
|
|
66
|
+
await first
|
|
67
|
+
expect(calls.count).toBe(1)
|
|
68
|
+
|
|
69
|
+
const refresh = guard.probe() // e.g. after a connect — re-reads
|
|
70
|
+
resolve()
|
|
71
|
+
await refresh
|
|
72
|
+
expect(calls.count).toBe(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('a probe() refresh in flight is shared by a concurrent ensureProbed()', async () => {
|
|
76
|
+
const { run, calls, resolve } = deferredRun()
|
|
77
|
+
const guard = useSingleFlightProbe(run, () => 'ws1')
|
|
78
|
+
|
|
79
|
+
const refresh = guard.probe()
|
|
80
|
+
const ensured = guard.ensureProbed() // rides the in-flight probe rather than firing a duplicate
|
|
81
|
+
expect(calls.count).toBe(1)
|
|
82
|
+
resolve()
|
|
83
|
+
await Promise.all([refresh, ensured])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('a superseded out-of-order completion does not stamp a stale probedId', async () => {
|
|
87
|
+
// ws1 probe starts, then a switch to ws2 starts a second — and ws2 (the newer, current board)
|
|
88
|
+
// resolves BEFORE the older ws1 probe. The late ws1 completion must NOT record ws1 as the
|
|
89
|
+
// settled board, else the next ensureProbed() for ws2 would redundantly re-run.
|
|
90
|
+
let id = 'ws1'
|
|
91
|
+
let n = 0
|
|
92
|
+
let resolve1!: () => void
|
|
93
|
+
let resolve2!: () => void
|
|
94
|
+
const run = vi.fn(() => {
|
|
95
|
+
n++
|
|
96
|
+
return new Promise<void>((r) => (n === 1 ? (resolve1 = r) : (resolve2 = r)))
|
|
97
|
+
})
|
|
98
|
+
const { ensureProbed } = useSingleFlightProbe(run, () => id)
|
|
99
|
+
|
|
100
|
+
const p1 = ensureProbed() // starts ws1
|
|
101
|
+
id = 'ws2'
|
|
102
|
+
const p2 = ensureProbed() // starts ws2 (a switch — different id)
|
|
103
|
+
expect(run).toHaveBeenCalledTimes(2)
|
|
104
|
+
|
|
105
|
+
resolve2() // the current board settles first
|
|
106
|
+
await p2
|
|
107
|
+
resolve1() // the superseded older probe settles late
|
|
108
|
+
await p1
|
|
109
|
+
|
|
110
|
+
// ws2 is the current, settled board → ensureProbed() is a no-op, not a third (redundant) run.
|
|
111
|
+
await ensureProbed()
|
|
112
|
+
expect(run).toHaveBeenCalledTimes(2)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight guard for a per-workspace integration probe (app-startup initiative, item 12).
|
|
3
|
+
*
|
|
4
|
+
* On a board open the same probe is fired from several places at once — e.g. `github.probe()` runs
|
|
5
|
+
* from both the board page (to resolve the onboarding gate) and the SideBar, and the SideBar fans
|
|
6
|
+
* out five more (`documents` / `tasks` / `slack` / `library` / provider connections). Each was an
|
|
7
|
+
* independent network call, and a re-mount re-ran them. This wraps a store's probe with two
|
|
8
|
+
* behaviours keyed on the active workspace id:
|
|
9
|
+
*
|
|
10
|
+
* - {@link probe} — always re-runs the probe (deliberate refresh, e.g. after a connect), but a
|
|
11
|
+
* burst of concurrent callers on ONE board open shares the single in-flight request.
|
|
12
|
+
* - {@link ensureProbed} — runs the probe AT MOST ONCE per workspace: a no-op when this board is
|
|
13
|
+
* already probed, or the shared in-flight promise otherwise. This is what the on-board-open
|
|
14
|
+
* fan-out uses, so the duplicate/refire collapses to one call — while a workspace SWITCH (a new
|
|
15
|
+
* id) still re-probes, since connections are per board.
|
|
16
|
+
*
|
|
17
|
+
* The id-keying means no explicit reset on workspace change: a call for a different id than the last
|
|
18
|
+
* completed probe re-runs. `run` reads whatever workspace-scoped state it needs itself; `currentId`
|
|
19
|
+
* only supplies the key (and the "which board did this settle for" record).
|
|
20
|
+
*/
|
|
21
|
+
interface SingleFlightProbe {
|
|
22
|
+
probe: () => Promise<void>
|
|
23
|
+
ensureProbed: () => Promise<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useSingleFlightProbe(
|
|
27
|
+
run: () => Promise<void>,
|
|
28
|
+
currentId: () => string | null,
|
|
29
|
+
): SingleFlightProbe {
|
|
30
|
+
let inFlight: Promise<void> | null = null
|
|
31
|
+
let inFlightId: string | null = null
|
|
32
|
+
let probedId: string | null = null
|
|
33
|
+
|
|
34
|
+
function start(id: string | null): Promise<void> {
|
|
35
|
+
const p = Promise.resolve(run()).finally(() => {
|
|
36
|
+
// Only record "settled for this board" / clear the in-flight slot when a NEWER probe hasn't
|
|
37
|
+
// superseded us. Otherwise an out-of-order completion — an older probe for board A resolving
|
|
38
|
+
// after a newer probe for board B — would stamp probedId back to A and force a redundant
|
|
39
|
+
// re-probe of B on the next ensureProbed. The newer probe owns `inFlight`, so it records the
|
|
40
|
+
// board that's actually current.
|
|
41
|
+
if (inFlight === p) {
|
|
42
|
+
probedId = id
|
|
43
|
+
inFlight = null
|
|
44
|
+
inFlightId = null
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
inFlight = p
|
|
48
|
+
inFlightId = id
|
|
49
|
+
return p
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function probe(): Promise<void> {
|
|
53
|
+
const id = currentId()
|
|
54
|
+
// Share an already-running probe for the same board (the concurrent-burst case); otherwise
|
|
55
|
+
// start a fresh one — a deliberate refresh must always re-read.
|
|
56
|
+
if (inFlight && inFlightId === id) return inFlight
|
|
57
|
+
return start(id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureProbed(): Promise<void> {
|
|
61
|
+
const id = currentId()
|
|
62
|
+
// Already settled for this board and nothing running → nothing to do.
|
|
63
|
+
if (probedId === id && !inFlight) return Promise.resolve()
|
|
64
|
+
// A probe for this board is in flight → ride it rather than firing a duplicate.
|
|
65
|
+
if (inFlight && inFlightId === id) return inFlight
|
|
66
|
+
return start(id)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { probe, ensureProbed }
|
|
70
|
+
}
|