@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.
@@ -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
- onMounted(() => auth.bootstrap())
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 picks the service's directory before adding (and may add
12
- // more than one, a subset of the repo's services).
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
- const selectedDirectory = ref<string | undefined>(undefined)
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
- selectedDirectory.value = undefined
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
- selectedDirectory.value = undefined
184
+ selectedDirectories.value = []
156
185
  configuredBlockId.value = undefined
157
186
  })
158
187
 
159
188
  function resetSelection() {
160
189
  selectedRepoId.value = undefined
161
- selectedDirectory.value = undefined
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 (test
190
- // infra + fragments) right here — the same controls as the inspector. A monorepo can
191
- // host several services, so adding another keeps the modal open; a whole-repo service
192
- // can only be added once (its repo is then on the board).
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 monorepo service needs a chosen directory; a whole-repo service can be added once.
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
- (isMonorepo.value ? !!selectedDirectory.value : !configuredBlockId.value),
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
- directory: isMonorepo.value ? selectedDirectory.value : undefined,
227
- isMonorepo: isMonorepo.value,
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
- // Ready to pick another monorepo service (the just-added directory is taken).
246
- selectedDirectory.value = undefined
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
- <div v-if="selectedRepoId !== undefined" class="space-y-3">
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="mb-2 text-xs text-slate-400">
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
- <p class="mt-2 truncate text-xs text-slate-400">
354
- <template v-if="selectedDirectory">
355
- {{ t('github.addService.serviceDirectory') }}
356
- <code class="text-slate-200">{{ selectedDirectory }}</code>
357
- </template>
358
- <template v-else>{{ t('github.addService.noDirectorySelected') }}</template>
359
- </p>
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!, directory: configuredDirectory }"
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
- <UButton v-if="configuredBlock" color="neutral" variant="soft" size="sm" @click="done">
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="!configuredBlock || isMonorepo"
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<{ 'update:modelValue': [string | undefined] }>()
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
- emit('update:modelValue', path)
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="modelValue === entry.path ? 'primary' : 'neutral'"
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 v-if="mode === 'dir' && currentPath" class="mt-2 flex justify-end">
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="modelValue === currentPath ? 'primary' : 'neutral'"
209
+ :color="isPicked(currentPath) ? 'primary' : 'neutral'"
172
210
  @click="pick(currentPath)"
173
211
  >
174
- {{ t('github.repoTree.useThisFolder') }}
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
- void documents.probe()
104
- void tasks.probe()
105
- void github.probe()
106
- void slack.probe()
107
- void library.probe()
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
+ }