@cat-factory/app 0.259.3 → 0.261.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/README.md +34 -0
- package/app/components/auth/LoginScreen.vue +4 -3
- package/app/components/board/BoardCanvas.vue +9 -1
- package/app/components/board/TaskDependencyEdges.vue +35 -26
- package/app/components/github/AddServiceFromRepoModal.vue +132 -29
- package/app/components/settings/McpAuthorizeScreen.vue +262 -0
- package/app/composables/api/mcpAuthorization.ts +30 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useBoardActivity.ts +111 -0
- package/app/composables/useSettlingRaf.ts +32 -0
- package/app/composables/useTaskExpansion.ts +29 -11
- package/app/pages/mcp-authorize.vue +7 -0
- package/app/stores/auth/session.ts +14 -2
- package/app/stores/board/placement.ts +12 -3
- package/app/stores/board.spec.ts +22 -2
- package/app/utils/edgeSegments.spec.ts +49 -0
- package/app/utils/edgeSegments.ts +49 -0
- package/app/utils/monorepoImport.spec.ts +120 -0
- package/app/utils/monorepoImport.ts +154 -0
- package/app/utils/postSignIn.spec.ts +29 -0
- package/app/utils/postSignIn.ts +29 -0
- package/app/utils/settlingLoop.spec.ts +236 -0
- package/app/utils/settlingLoop.ts +101 -0
- package/i18n/locales/de.json +45 -1
- package/i18n/locales/en.json +45 -1
- package/i18n/locales/es.json +45 -1
- package/i18n/locales/fr.json +45 -1
- package/i18n/locales/he.json +45 -1
- package/i18n/locales/it.json +45 -1
- package/i18n/locales/ja.json +45 -1
- package/i18n/locales/pl.json +45 -1
- package/i18n/locales/tr.json +45 -1
- package/i18n/locales/uk.json +45 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -68,6 +68,40 @@ Order within the column is by what the user loses by not reading it now; the too
|
|
|
68
68
|
|
|
69
69
|
`app/components/layout/BoardTopOverlays.spec.ts` enforces the no-self-placement half, reading the member list from the component's own imports.
|
|
70
70
|
|
|
71
|
+
### A board driver that MEASURES the DOM runs off the activity pulse, never a bare RAF
|
|
72
|
+
|
|
73
|
+
Two board features cannot be derived from the stores alone: the dependency-edge overlay needs
|
|
74
|
+
each card's on-screen rectangle, and the task-expansion driver needs the topmost card under the
|
|
75
|
+
pointer. Both used `useRafFn`, so an open board paid O(edges) `querySelector` plus forced layout
|
|
76
|
+
reads sixty times a second with nothing moving, and the edge overlay reassigned an
|
|
77
|
+
equal-but-new segment array every frame on top of that.
|
|
78
|
+
|
|
79
|
+
**A new driver of that kind pairs `useSettlingRaf(compute)` with the canvas pulse
|
|
80
|
+
(`useBoardActivity`), and `compute` reports honestly whether it changed anything.** The pulse
|
|
81
|
+
answers "something may have started moving" (DOM mutations under the canvas, its resize, the
|
|
82
|
+
Vue Flow camera, pointer/wheel/scroll gestures) and the settling loop carries that wake through
|
|
83
|
+
the animation that follows, parking once the output has held still for a few frames. Neither
|
|
84
|
+
half works alone: a signal fires one frame BEFORE the transition it starts has any geometry, and
|
|
85
|
+
a bare frame loop never stops.
|
|
86
|
+
|
|
87
|
+
Two things this cost, both worth knowing before adding a third driver. `compute` returning
|
|
88
|
+
`true` unconditionally silently restores the old behaviour, which is why the loop's contract is
|
|
89
|
+
stated in terms of what the user can see rather than what the function did. And the pulse
|
|
90
|
+
watches `style`/`class` attributes but not the geometry attributes the overlay itself writes,
|
|
91
|
+
because a driver whose own output pulsed it awake would never settle.
|
|
92
|
+
|
|
93
|
+
A `compute` that THROWS parks the loop and lets the error reach the frame callback, so the next
|
|
94
|
+
pulse of any kind is what restarts it. Retrying the frame instead would turn one bad measurement
|
|
95
|
+
into a 60Hz error storm, and staying awake with no frame scheduled would make every later poke a
|
|
96
|
+
no-op and freeze the board for the session.
|
|
97
|
+
|
|
98
|
+
What the pulse cannot see is a reflow with no mutation and no gesture, a late-loading image or
|
|
99
|
+
font resizing a card. That leaves an arrow stale until the next pulse of any kind, which is the
|
|
100
|
+
deliberate trade: firing too often costs a handful of frames, and the alternative is the loop
|
|
101
|
+
that never sleeps.
|
|
102
|
+
|
|
103
|
+
`app/utils/settlingLoop.spec.ts` pins the loop against a hand-driven frame clock.
|
|
104
|
+
|
|
71
105
|
### A store must be instantiable outside a component `setup`
|
|
72
106
|
|
|
73
107
|
A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
|
|
@@ -5,6 +5,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
|
|
|
5
5
|
import type { VcsProvider } from '~/types/domain'
|
|
6
6
|
import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
|
|
7
7
|
import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
|
|
8
|
+
import { postSignInUrl } from '~/utils/postSignIn'
|
|
8
9
|
|
|
9
10
|
const auth = useAuthStore()
|
|
10
11
|
const { t } = useI18n()
|
|
@@ -62,7 +63,7 @@ async function submitPat(provider: PatProvider) {
|
|
|
62
63
|
patBusy.value = true
|
|
63
64
|
try {
|
|
64
65
|
await auth.patLogin({ provider })
|
|
65
|
-
if (typeof window !== 'undefined') window.location.assign(window.location
|
|
66
|
+
if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
|
|
66
67
|
} catch (e) {
|
|
67
68
|
patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
|
|
68
69
|
} finally {
|
|
@@ -105,7 +106,7 @@ async function submitPassword() {
|
|
|
105
106
|
await auth.passwordLogin({ email: email.value, password: password.value })
|
|
106
107
|
}
|
|
107
108
|
// Reload so the app boots with the new session.
|
|
108
|
-
if (typeof window !== 'undefined') window.location.assign(window.location
|
|
109
|
+
if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
|
|
109
110
|
} catch (e) {
|
|
110
111
|
error.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
|
|
111
112
|
} finally {
|
|
@@ -177,7 +178,7 @@ async function submitRemotePat() {
|
|
|
177
178
|
remotePatBusy.value = true
|
|
178
179
|
try {
|
|
179
180
|
await auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() })
|
|
180
|
-
if (typeof window !== 'undefined') window.location.assign(window.location
|
|
181
|
+
if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
|
|
181
182
|
} catch (e) {
|
|
182
183
|
remotePatError.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
|
|
183
184
|
} finally {
|
|
@@ -7,6 +7,7 @@ import TaskDependencyEdges from './TaskDependencyEdges.vue'
|
|
|
7
7
|
import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
|
|
8
8
|
import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
|
|
9
9
|
import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
|
|
10
|
+
import { provideBoardActivity } from '~/composables/useBoardActivity'
|
|
10
11
|
import { useTaskExpansion } from '~/composables/useTaskExpansion'
|
|
11
12
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
13
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
@@ -46,7 +47,10 @@ const panOnDrag = computed<boolean | number[]>(() => boardPanMode(hasTouch.value
|
|
|
46
47
|
// centre-most of any that would overlap (see useTaskExpansion). Service frames have no
|
|
47
48
|
// such gate — they are always expanded to their task canvas.
|
|
48
49
|
const boardEl = ref<HTMLElement | null>(null)
|
|
49
|
-
|
|
50
|
+
// The canvas owns the "something may have moved" pulse both DOM-measuring drivers run off:
|
|
51
|
+
// this one directly, the dependency-edge overlay by injection. See useBoardActivity.
|
|
52
|
+
const boardActivity = provideBoardActivity(boardEl)
|
|
53
|
+
useTaskExpansion(boardEl, boardActivity)
|
|
50
54
|
|
|
51
55
|
// Only frames are board nodes. Dependencies live on tasks (rendered inside the
|
|
52
56
|
// frames), so there are no frame-to-frame edges on the canvas.
|
|
@@ -100,6 +104,10 @@ onNodeDragStop(({ node }) => {
|
|
|
100
104
|
|
|
101
105
|
onViewportChange((vp) => {
|
|
102
106
|
ui.zoom = vp.zoom
|
|
107
|
+
// Pan and zoom move every card on screen. Vue Flow does that by restyling its transform
|
|
108
|
+
// pane, which the pulse's observer would also catch, but the camera is too load-bearing for
|
|
109
|
+
// the overlays to depend on which DOM strategy Vue Flow uses to apply it.
|
|
110
|
+
boardActivity.pulse()
|
|
103
111
|
})
|
|
104
112
|
|
|
105
113
|
function onNodeClick({ node }: NodeMouseEvent) {
|
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref,
|
|
3
|
-
import {
|
|
2
|
+
import { ref, shallowRef, computed, watch } from 'vue'
|
|
3
|
+
import { useBoardActivity } from '~/composables/useBoardActivity'
|
|
4
|
+
import { useSettlingRaf } from '~/composables/useSettlingRaf'
|
|
5
|
+
import { commitSegments, type EdgeSegment } from '~/utils/edgeSegments'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Draws dependency arrows between task cards as an SVG overlay on top of the
|
|
7
9
|
* board. Tasks are plain DOM nodes (inside frame cards), so we resolve their
|
|
8
|
-
* on-screen rectangles by `[data-block-id]`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* on-screen rectangles by `[data-block-id]` — this makes arrows follow pan /
|
|
11
|
+
* zoom / drag / expand for free. When a task's frame is collapsed (its card
|
|
12
|
+
* isn't rendered), the arrow anchors to the frame card instead.
|
|
13
|
+
*
|
|
14
|
+
* Measuring is O(edges) `querySelector` + forced layout reads, so it runs only
|
|
15
|
+
* while something is actually moving: the board's activity pulse wakes it and
|
|
16
|
+
* `useSettlingRaf` parks it again once the resolved segments hold still.
|
|
11
17
|
*/
|
|
12
18
|
const board = useBoardStore()
|
|
13
19
|
|
|
14
20
|
const svg = ref<SVGSVGElement | null>(null)
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
const segments = ref<Seg[]>([])
|
|
22
|
+
const segments = shallowRef<EdgeSegment[]>([])
|
|
18
23
|
// Epic→member membership links (distinct style from dependency edges).
|
|
19
|
-
|
|
20
|
-
const memberSegments = ref<MemberSeg[]>([])
|
|
24
|
+
const memberSegments = shallowRef<EdgeSegment[]>([])
|
|
21
25
|
// Frontend frame → bound service frame links (from a frontend's backend bindings).
|
|
22
|
-
|
|
23
|
-
const frontendSegments = ref<FrontendSeg[]>([])
|
|
26
|
+
const frontendSegments = shallowRef<EdgeSegment[]>([])
|
|
24
27
|
// Service frame → connected provider service frame links (from serviceConnections).
|
|
25
|
-
|
|
26
|
-
const connectionSegments = ref<ConnectionSeg[]>([])
|
|
28
|
+
const connectionSegments = shallowRef<EdgeSegment[]>([])
|
|
27
29
|
|
|
28
30
|
// task → its dependencies, both ends being tasks
|
|
29
31
|
const taskDeps = computed(() => {
|
|
@@ -129,8 +131,8 @@ function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
|
|
|
129
131
|
function linkSegments(
|
|
130
132
|
links: { id: string; source: string; target: string }[],
|
|
131
133
|
origin: DOMRect,
|
|
132
|
-
):
|
|
133
|
-
const out:
|
|
134
|
+
): EdgeSegment[] {
|
|
135
|
+
const out: EdgeSegment[] = []
|
|
134
136
|
for (const link of links) {
|
|
135
137
|
const seg = segmentBetween(link.source, link.target, origin)
|
|
136
138
|
if (seg) out.push({ id: link.id, ...seg })
|
|
@@ -138,27 +140,34 @@ function linkSegments(
|
|
|
138
140
|
return out
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
/** Re-measure every overlay link; reports whether any of them moved. */
|
|
144
|
+
function recompute(): boolean {
|
|
142
145
|
const el = svg.value
|
|
143
|
-
if (!el) return
|
|
146
|
+
if (!el) return false
|
|
144
147
|
const origin = el.getBoundingClientRect()
|
|
145
148
|
|
|
146
|
-
const
|
|
149
|
+
const deps: EdgeSegment[] = []
|
|
147
150
|
for (const d of taskDeps.value) {
|
|
148
151
|
const seg = segmentBetween(d.source, d.target, origin)
|
|
149
152
|
if (!seg) continue
|
|
150
|
-
|
|
153
|
+
deps.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
|
|
151
154
|
}
|
|
152
|
-
segments.value = next
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
// An array literal, so every list is committed before the result is reduced: a `||` chain
|
|
157
|
+
// would short-circuit and leave the later overlays drawn at stale coordinates.
|
|
158
|
+
return [
|
|
159
|
+
commitSegments(segments, deps),
|
|
160
|
+
commitSegments(memberSegments, linkSegments(epicLinks.value, origin)),
|
|
161
|
+
commitSegments(frontendSegments, linkSegments(frontendLinks.value, origin)),
|
|
162
|
+
commitSegments(connectionSegments, linkSegments(connectionLinks.value, origin)),
|
|
163
|
+
].some(Boolean)
|
|
157
164
|
}
|
|
158
165
|
|
|
159
|
-
const {
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
const { poke } = useSettlingRaf(recompute)
|
|
167
|
+
useBoardActivity(poke)
|
|
168
|
+
// A link set can change with no visible change to any card (toggling a dependency between two
|
|
169
|
+
// tasks draws an arrow and nothing else), which the DOM-level pulse would never see.
|
|
170
|
+
watch([taskDeps, epicLinks, frontendLinks, connectionLinks], poke)
|
|
162
171
|
</script>
|
|
163
172
|
|
|
164
173
|
<template>
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
// browses its tree and multi-selects the service directories to add — from ANY
|
|
13
13
|
// parent folder, in one pass — then adds them all at once. Directories that
|
|
14
14
|
// already back a service on this board are shown but not selectable.
|
|
15
|
+
//
|
|
16
|
+
// One of those directories may be marked the FRONTEND for the rest: it is created as a
|
|
17
|
+
// `frontend` frame instead of a service, pinned to its subdirectory, and bound to every
|
|
18
|
+
// backend added beside it (`frontendConfig.backendBindings`, the frontend→service board
|
|
19
|
+
// link). Every frontend frame the import creates also gets its subdirectory recorded on
|
|
20
|
+
// `frontendConfig`, marked or not. The rules live in `~/utils/monorepoImport`, which also
|
|
21
|
+
// explains why the bindings carry no env-var names.
|
|
15
22
|
import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
|
|
23
|
+
import type { CreatedMonorepoFrame } from '~/utils/monorepoImport'
|
|
16
24
|
import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
|
|
17
25
|
import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
|
|
18
26
|
import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
|
|
@@ -170,9 +178,53 @@ const addedDirectories = computed<string[]>(() => {
|
|
|
170
178
|
})
|
|
171
179
|
const addedDirSet = computed(() => new Set(addedDirectories.value))
|
|
172
180
|
|
|
181
|
+
// What the next "Add N services" will actually create: the cart minus anything already backing a
|
|
182
|
+
// service. THE population every frontend-mark decision reads, and the one `addServices` iterates.
|
|
183
|
+
// The two can differ: a partial failure leaves the cart intact while its earlier creates stand, so
|
|
184
|
+
// judging the mark by the raw cart would offer it (and count it) for frames that already exist.
|
|
185
|
+
const pendingDirectories = computed(() =>
|
|
186
|
+
selectedDirectories.value.filter((d) => !addedDirSet.value.has(normalizeRepoPath(d))),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// The one picked directory marked as the frontend for the others, or undefined when the
|
|
190
|
+
// selection is all backends. Empty string is the select's "none" option.
|
|
191
|
+
const frontendDirectory = ref<string | undefined>(undefined)
|
|
192
|
+
|
|
193
|
+
// Whether the mark is on offer at all (role must be `service`, at least two directories to
|
|
194
|
+
// create): see `canDesignateFrontend`. The picker is hidden otherwise, so drop a mark that a role
|
|
195
|
+
// change has made unofferable rather than leaving it to act unseen.
|
|
196
|
+
const frontendOffered = computed(() =>
|
|
197
|
+
canDesignateFrontend(selectedType.value, pendingDirectories.value.length),
|
|
198
|
+
)
|
|
199
|
+
watch(frontendOffered, (offered) => {
|
|
200
|
+
if (!offered) frontendDirectory.value = undefined
|
|
201
|
+
})
|
|
202
|
+
// The pending set is the option list, so a directory that leaves it (removed from the cart, or
|
|
203
|
+
// created by an earlier add) can no longer be the mark. The computed re-runs on the cart's
|
|
204
|
+
// in-place mutations (`push`/`splice`), so no deep watch is needed on top of it.
|
|
205
|
+
watch(pendingDirectories, (dirs) => {
|
|
206
|
+
if (frontendDirectory.value && !dirs.includes(frontendDirectory.value)) {
|
|
207
|
+
frontendDirectory.value = undefined
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const frontendItems = computed(() => [
|
|
212
|
+
{ label: t('github.addService.frontendNone'), value: '' },
|
|
213
|
+
...pendingDirectories.value.map((d) => ({ label: d, value: d })),
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
// USelect needs a present value for its "none" row; the mark itself stays absent-or-a-path.
|
|
217
|
+
const frontendSelection = computed({
|
|
218
|
+
get: () => frontendDirectory.value ?? '',
|
|
219
|
+
set: (value: string) => {
|
|
220
|
+
frontendDirectory.value = value || undefined
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
|
|
173
224
|
function toggleMonorepo(value: boolean) {
|
|
174
225
|
isMonorepo.value = value
|
|
175
226
|
selectedDirectories.value = []
|
|
227
|
+
frontendDirectory.value = undefined
|
|
176
228
|
}
|
|
177
229
|
|
|
178
230
|
// Add/remove a directory from the cart. Guards against an already-added directory (the
|
|
@@ -209,12 +261,14 @@ watch(selectedRepoId, (id) => {
|
|
|
209
261
|
}
|
|
210
262
|
isMonorepo.value = selectedRepo.value?.isMonorepo === true
|
|
211
263
|
selectedDirectories.value = []
|
|
264
|
+
frontendDirectory.value = undefined
|
|
212
265
|
configuredBlockId.value = undefined
|
|
213
266
|
})
|
|
214
267
|
|
|
215
268
|
function resetSelection() {
|
|
216
269
|
selectedRepoId.value = undefined
|
|
217
270
|
selectedDirectories.value = []
|
|
271
|
+
frontendDirectory.value = undefined
|
|
218
272
|
isMonorepo.value = false
|
|
219
273
|
configuredBlockId.value = undefined
|
|
220
274
|
resetRepoSearch()
|
|
@@ -265,18 +319,13 @@ const canAddServices = computed(
|
|
|
265
319
|
!needsConnection.value &&
|
|
266
320
|
selectedRepoId.value !== undefined &&
|
|
267
321
|
isMonorepo.value &&
|
|
268
|
-
|
|
322
|
+
pendingDirectories.value.length > 0,
|
|
269
323
|
)
|
|
270
324
|
|
|
271
325
|
// Directories the user has picked but NOT yet committed via "Add N services". Closing the
|
|
272
326
|
// modal ("Done") would silently discard them — almost never what the user wants — so the
|
|
273
|
-
// footer's Done is disabled while any remain (see the template).
|
|
274
|
-
|
|
275
|
-
const hasPendingSelection = computed(
|
|
276
|
-
() =>
|
|
277
|
-
isMonorepo.value &&
|
|
278
|
-
selectedDirectories.value.some((d) => !addedDirSet.value.has(normalizeRepoPath(d))),
|
|
279
|
-
)
|
|
327
|
+
// footer's Done is disabled while any remain (see the template).
|
|
328
|
+
const hasPendingSelection = computed(() => isMonorepo.value && pendingDirectories.value.length > 0)
|
|
280
329
|
|
|
281
330
|
async function add() {
|
|
282
331
|
if (!canAdd.value || selectedRepoId.value === undefined) return
|
|
@@ -315,38 +364,71 @@ async function add() {
|
|
|
315
364
|
}
|
|
316
365
|
}
|
|
317
366
|
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
367
|
+
// What the success toast says about the frontend wiring, which is the half of the add that can
|
|
368
|
+
// fail on its own. A landed mark names the directory and points at the inspector for the env-var
|
|
369
|
+
// names the import deliberately leaves empty; a patch that did not persist says SO, because the
|
|
370
|
+
// frames are on the board either way and a silent omission reads exactly like a clean import. The
|
|
371
|
+
// failure note covers an undesignated frontend frame too: it lost its subdirectory, so the harness
|
|
372
|
+
// would build the repo root.
|
|
373
|
+
function frontendNote(designatedDirectory: string | undefined, wiringLanded: boolean): string {
|
|
374
|
+
if (!wiringLanded) return t('github.addService.toast.frontendWiringFailedNote')
|
|
375
|
+
if (!designatedDirectory) return ''
|
|
376
|
+
return t('github.addService.toast.frontendLinkedNote', { directory: designatedDirectory })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Add every pending directory as its own frame, in one action. Each add lays the frame out in free
|
|
380
|
+
// space (seeing the ones added earlier in the loop, so they don't overlap); the projection is
|
|
381
|
+
// refreshed and the camera centres on the last one. The just-added directories then move to
|
|
382
|
+
// `addedDirectories`, so the cart is cleared and the tree marks them "added", ready to pick more
|
|
383
|
+
// (from any folder) or close. That is why the pending set is SNAPSHOTTED before the first await:
|
|
384
|
+
// each create refreshes the projection, so the live computed shrinks under the loop.
|
|
385
|
+
//
|
|
386
|
+
// A created `frontend` frame is then patched with its `frontendConfig`: its subdirectory always,
|
|
387
|
+
// plus a binding per sibling frame when it is the marked one. Those patches can only run after the
|
|
388
|
+
// loop, because the bindings name block ids the creates mint. The frame being wired is the one the
|
|
389
|
+
// PLAN designated, never whichever entry happens to carry `type: 'frontend'` (see
|
|
390
|
+
// `MonorepoImportEntry.designatedFrontend`). A patch that does not land leaves its frames standing
|
|
391
|
+
// and is REPORTED: `updateBlock` toasts its own failure and answers whether it persisted, so the
|
|
392
|
+
// success toast claims only the links that were actually written.
|
|
323
393
|
async function addServices() {
|
|
324
394
|
if (!canAddServices.value || selectedRepoId.value === undefined) return
|
|
325
|
-
const dirs =
|
|
395
|
+
const dirs = [...pendingDirectories.value]
|
|
326
396
|
if (dirs.length === 0) return
|
|
397
|
+
// The mark is handed over raw: `planMonorepoImport` applies `canDesignateFrontend` itself over
|
|
398
|
+
// the very directories it is creating, so there is no second copy of that condition to drift.
|
|
399
|
+
const plan = planMonorepoImport(dirs, selectedType.value, frontendDirectory.value)
|
|
400
|
+
const designatedDirectory = plan.find((entry) => entry.designatedFrontend)?.directory
|
|
327
401
|
adding.value = true
|
|
328
402
|
try {
|
|
329
|
-
|
|
330
|
-
for (const
|
|
331
|
-
|
|
332
|
-
directory,
|
|
403
|
+
const created: CreatedMonorepoFrame[] = []
|
|
404
|
+
for (const entry of plan) {
|
|
405
|
+
const block = await board.addServiceFromRepo(selectedRepoId.value, {
|
|
406
|
+
directory: entry.directory,
|
|
333
407
|
isMonorepo: true,
|
|
334
|
-
type:
|
|
408
|
+
type: entry.type,
|
|
335
409
|
position: freeFramePosition(),
|
|
336
410
|
})
|
|
411
|
+
created.push({ blockId: block.id, entry })
|
|
412
|
+
}
|
|
413
|
+
let wiringLanded = true
|
|
414
|
+
for (const patch of planFrontendConfigPatches(created)) {
|
|
415
|
+
const persisted = await board.updateBlock(patch.blockId, { frontendConfig: patch.config })
|
|
416
|
+
if (!persisted) wiringLanded = false
|
|
337
417
|
}
|
|
338
418
|
await github.load()
|
|
339
|
-
|
|
419
|
+
const lastBlockId = created.at(-1)?.blockId
|
|
420
|
+
if (lastBlockId) await focusFrame(lastBlockId)
|
|
340
421
|
selectedDirectories.value = []
|
|
341
422
|
toast.add({
|
|
342
423
|
title: t('github.addService.toast.servicesAddedTitle'),
|
|
343
|
-
description:
|
|
344
|
-
'github.addService.toast.servicesAddedDescription',
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
424
|
+
description: [
|
|
425
|
+
t('github.addService.toast.servicesAddedDescription', { count: dirs.length }, dirs.length),
|
|
426
|
+
frontendNote(designatedDirectory, wiringLanded),
|
|
427
|
+
]
|
|
428
|
+
.filter(Boolean)
|
|
429
|
+
.join(' '),
|
|
430
|
+
icon: wiringLanded ? 'i-lucide-check' : 'i-lucide-triangle-alert',
|
|
431
|
+
color: wiringLanded ? 'success' : 'warning',
|
|
350
432
|
})
|
|
351
433
|
} catch (e) {
|
|
352
434
|
toast.add({
|
|
@@ -498,6 +580,25 @@ function done() {
|
|
|
498
580
|
<p v-else class="text-xs text-slate-500">
|
|
499
581
|
{{ t('github.addService.noServicesSelected') }}
|
|
500
582
|
</p>
|
|
583
|
+
|
|
584
|
+
<!-- Mark one pick as the frontend for the others: it is created as a frontend
|
|
585
|
+
app and bound to every backend added beside it. Only offered while the
|
|
586
|
+
mark would wire something (see `canDesignateFrontend`). -->
|
|
587
|
+
<UFormField
|
|
588
|
+
v-if="frontendOffered"
|
|
589
|
+
:label="t('github.addService.frontendLabel')"
|
|
590
|
+
:description="t('github.addService.frontendHint')"
|
|
591
|
+
>
|
|
592
|
+
<USelect
|
|
593
|
+
v-model="frontendSelection"
|
|
594
|
+
:items="frontendItems"
|
|
595
|
+
value-key="value"
|
|
596
|
+
size="sm"
|
|
597
|
+
class="w-full"
|
|
598
|
+
data-testid="add-service-frontend-select"
|
|
599
|
+
/>
|
|
600
|
+
</UFormField>
|
|
601
|
+
|
|
501
602
|
<div class="flex justify-end">
|
|
502
603
|
<UButton
|
|
503
604
|
color="primary"
|
|
@@ -507,11 +608,13 @@ function done() {
|
|
|
507
608
|
:disabled="!canAddServices"
|
|
508
609
|
@click="addServices"
|
|
509
610
|
>
|
|
611
|
+
<!-- Counts what the click will CREATE, not the raw cart: an entry whose frame
|
|
612
|
+
already exists (a retry after a partial failure) is not added again. -->
|
|
510
613
|
{{
|
|
511
614
|
t(
|
|
512
615
|
'github.addService.addServices',
|
|
513
|
-
{ count:
|
|
514
|
-
|
|
616
|
+
{ count: pendingDirectories.length },
|
|
617
|
+
pendingDirectories.length,
|
|
515
618
|
)
|
|
516
619
|
}}
|
|
517
620
|
</UButton>
|