@cat-factory/app 0.237.0 → 0.237.1

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.
@@ -3,10 +3,14 @@ import type { LodLevel } from '~/types/domain'
3
3
  import { zoomToLod } from '~/composables/useSemanticZoom'
4
4
 
5
5
  /**
6
- * The board-navigation slice of the UI store: selection / focus, canvas zoom + the derived
7
- * level-of-detail, and the (retained) expanded-frame set. Hot paths (zoom/pan/select) live here,
8
- * isolated from the modal + result-view state, per refactoring candidate #4. Composed into
9
- * {@link useUiStore}; the returned refs/actions keep their names, so consumers are unchanged.
6
+ * The board-navigation slice of the UI store: selection / focus, and canvas zoom plus the derived
7
+ * level-of-detail. Hot paths (zoom/pan/select) live here, isolated from the modal + result-view
8
+ * state, per refactoring candidate #4. Composed into {@link useUiStore}; the returned refs/actions
9
+ * keep their names, so consumers are unchanged.
10
+ *
11
+ * There is deliberately no per-frame expanded/collapsed state: a service is always expanded to its
12
+ * task canvas, at every zoom level, so the board layout is fixed. The set that used to hold it
13
+ * outlived the last branch that read it and went with them.
10
14
  */
11
15
  export function createUiNavigation() {
12
16
  const selectedBlockId = ref<string | null>(null)
@@ -17,29 +21,6 @@ export function createUiNavigation() {
17
21
 
18
22
  const lod = computed<LodLevel>(() => zoomToLod(zoom.value))
19
23
 
20
- /** Frames the user has manually expanded to reveal their tasks. */
21
- const expandedFrames = ref<Set<string>>(new Set())
22
-
23
- function toggleFrame(id: string) {
24
- const next = new Set(expandedFrames.value)
25
- if (next.has(id)) next.delete(id)
26
- else next.add(id)
27
- expandedFrames.value = next
28
- }
29
-
30
- function expandFrame(id: string) {
31
- if (expandedFrames.value.has(id)) return
32
- expandedFrames.value = new Set(expandedFrames.value).add(id)
33
- }
34
-
35
- /** Services are always expanded to their task canvas, at every zoom level, so the
36
- * board layout is fixed: panning never changes it and zooming has no expand/collapse
37
- * transition to snap on. (`expandedFrames`/`toggleFrame` are retained for callers but
38
- * no longer gate rendering.) */
39
- function isFrameExpanded(_id: string) {
40
- return true
41
- }
42
-
43
24
  function select(id: string | null) {
44
25
  selectedBlockId.value = id
45
26
  }
@@ -53,10 +34,6 @@ export function createUiNavigation() {
53
34
  focusBlockId,
54
35
  zoom,
55
36
  lod,
56
- expandedFrames,
57
- toggleFrame,
58
- expandFrame,
59
- isFrameExpanded,
60
37
  select,
61
38
  focus,
62
39
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where a board-authoring surface creates: the containers a new task may land in, and how that
3
+ * answer follows a live board.
4
+ *
5
+ * Pure so the reconcile below can be pinned by a unit test. `<TaskImportModal>` and
6
+ * `<BugHuntModal>` ask the same two-part question (a source to read, a container to land in) and
7
+ * are opened from the same frame-header buttons, so both read this through
8
+ * {@link useContainerTargets} rather than each carrying its own copy.
9
+ */
10
+ import type { Block } from '~/types/domain'
11
+
12
+ /** A container offered as a select item. */
13
+ export interface ContainerTarget {
14
+ label: string
15
+ value: string
16
+ }
17
+
18
+ /** Only a service frame or one of its modules can hold a task. */
19
+ export function isTaskContainer(block: Block | undefined): block is Block {
20
+ return !!block && (block.level === 'frame' || block.level === 'module')
21
+ }
22
+
23
+ /**
24
+ * The containers on offer, given the frame the surface was opened from (if any).
25
+ *
26
+ * Opened from a service frame the answer is SCOPED to that frame: the frame itself plus its
27
+ * modules, each labelled by its own title because the frame is already named on the surface. The
28
+ * frame settles WHICH SERVICE the work belongs to, which is the part a header button can answer;
29
+ * it does not settle frame-or-which-module, so a frame that has modules still owes the user a
30
+ * choice. Opened standalone there is no frame behind it, so every container on the board is a
31
+ * candidate and a module carries its parent's title to keep the choice unambiguous.
32
+ *
33
+ * `pinned` is resolved through the board by the caller rather than trusted as an id, so a frame
34
+ * deleted while the surface sat open widens back to the whole board instead of scoping to
35
+ * something nothing can be created in.
36
+ */
37
+ export function containerTargets(
38
+ blocks: readonly Block[],
39
+ pinned: Block | undefined,
40
+ ): ContainerTarget[] {
41
+ if (pinned) {
42
+ // Modules cannot nest, so a pinned module is already the only answer.
43
+ if (pinned.level === 'module') return [{ label: pinned.title, value: pinned.id }]
44
+ const modules = blocks.filter((b) => b.level === 'module' && b.parentId === pinned.id)
45
+ return [pinned, ...modules].map((b) => ({ label: b.title, value: b.id }))
46
+ }
47
+ const byId = new Map(blocks.map((b) => [b.id, b]))
48
+ return blocks.filter(isTaskContainer).map((b) => ({
49
+ label:
50
+ b.level === 'module' ? `${byId.get(b.parentId ?? '')?.title ?? '?'} › ${b.title}` : b.title,
51
+ value: b.id,
52
+ }))
53
+ }
54
+
55
+ /**
56
+ * The container a surface should hold as the board changes underneath it: the current selection
57
+ * while it is still on offer, else the first one that is.
58
+ *
59
+ * Needed because "where does this land" is answered once, when the surface opens, and the board is
60
+ * live: the frame it was opened from can be deleted, or a module added to it, while it sits there.
61
+ * A selection left pointing at a deleted block is the worst of the three states, because nothing
62
+ * looks wrong: the picker renders with nothing selected while the issue search under it is still
63
+ * scoped to the block that is gone, and the create lands on an id the board no longer has.
64
+ */
65
+ export function reconcileContainer(
66
+ targets: readonly ContainerTarget[],
67
+ selected: string | undefined,
68
+ ): string | undefined {
69
+ if (selected && targets.some((t) => t.value === selected)) return selected
70
+ return targets[0]?.value
71
+ }
@@ -0,0 +1,258 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ buildConnectionSourceChoices,
4
+ buildSourceChoices,
5
+ connectableSources,
6
+ menuIsPickable,
7
+ reconcileSource,
8
+ sourceMenuItems,
9
+ } from './sourcePicker'
10
+ import type { TaskSourceState } from '~/types/domain'
11
+
12
+ /**
13
+ * The pure source-selection behind `<ContextIssuePicker>`, `<BugHuntModal>` and
14
+ * `<ContextDocumentPicker>`. Pins what the always-visible selector promises: the source in use is
15
+ * named even when it is the only one, a source the workspace hasn't got yet is offered as something
16
+ * to ADD (worded for its actual state), a menu with nothing to decide is not dressed as a control,
17
+ * and the selection stays valid as the offered set changes underneath it.
18
+ */
19
+ const state = (source: string, { available = true, enabled = true } = {}): TaskSourceState =>
20
+ ({
21
+ source,
22
+ label: source.toUpperCase(),
23
+ icon: `i-lucide-${source}`,
24
+ available,
25
+ enabled,
26
+ credentialFields: [],
27
+ refLabel: '',
28
+ refPlaceholder: '',
29
+ }) as unknown as TaskSourceState
30
+
31
+ describe('buildSourceChoices', () => {
32
+ it('lists a single offered tracker, marked active (the selector is never hidden)', () => {
33
+ const groups = buildSourceChoices([state('github')], 'github')
34
+ expect(groups).toEqual([
35
+ [
36
+ {
37
+ action: 'select',
38
+ source: 'github',
39
+ label: 'GITHUB',
40
+ icon: 'i-lucide-github',
41
+ active: true,
42
+ },
43
+ ],
44
+ ])
45
+ })
46
+
47
+ it('marks only the selected tracker active', () => {
48
+ const [offered] = buildSourceChoices([state('github'), state('jira')], 'jira')
49
+ expect(offered!.map((c) => [c.source, 'active' in c && c.active])).toEqual([
50
+ ['github', false],
51
+ ['jira', true],
52
+ ])
53
+ })
54
+
55
+ it('offers an unavailable tracker as `connect` and an available-but-off one as `enable`', () => {
56
+ const [, addable] = buildSourceChoices(
57
+ [state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
58
+ 'github',
59
+ )
60
+ expect(addable).toEqual([
61
+ { action: 'connect', source: 'jira', label: 'JIRA', icon: 'i-lucide-jira' },
62
+ { action: 'enable', source: 'linear', label: 'LINEAR', icon: 'i-lucide-linear' },
63
+ ])
64
+ })
65
+
66
+ // What a surface's "nothing connected yet" state renders its add buttons from: it flattens
67
+ // the groups, so every choice there has to be addable. A `select` leaking through would
68
+ // offer to connect a tracker the workspace already has.
69
+ it('yields only addable choices when the workspace offers no tracker', () => {
70
+ const choices = buildSourceChoices(
71
+ [state('jira', { available: false }), state('linear', { enabled: false })],
72
+ undefined,
73
+ ).flat()
74
+ expect(choices.map((c) => c.action)).toEqual(['connect', 'enable'])
75
+ })
76
+
77
+ it('drops empty groups so the menu renders no stray separator', () => {
78
+ expect(buildSourceChoices([state('github')], 'github')).toHaveLength(1)
79
+ expect(buildSourceChoices([state('jira', { available: false })], undefined)).toHaveLength(1)
80
+ expect(buildSourceChoices([], undefined)).toEqual([])
81
+ })
82
+ })
83
+
84
+ describe('connectableSources', () => {
85
+ const sources = [
86
+ { source: 'github', label: 'GitHub', icon: 'i-lucide-github' },
87
+ { source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' },
88
+ ]
89
+ const isConnected = (source: string) => source === 'github'
90
+
91
+ it('offers only the sources the workspace has not connected', () => {
92
+ const rows = connectableSources(sources, { isConnected, canConnect: true, available: true })
93
+ expect(rows.map((s) => s.source)).toEqual(['confluence'])
94
+ })
95
+
96
+ // Connecting stores a workspace credential and stays admin-tier while attaching moved to the
97
+ // member tier, so a member's add tier is withheld rather than rendered into a 403.
98
+ it('withholds every source from a user who may not connect one', () => {
99
+ expect(
100
+ connectableSources(sources, { isConnected, canConnect: false, available: true }),
101
+ ).toEqual([])
102
+ })
103
+
104
+ // The term the three former copies of this rule disagreed about. An integration the deployment
105
+ // has not configured has nothing to connect TO, so it is not the same fact as "already connected"
106
+ // and must not rest on the store happening to clear its source list.
107
+ it('withholds every source when the integration is unavailable to the deployment', () => {
108
+ expect(
109
+ connectableSources(sources, { isConnected: () => false, canConnect: true, available: false }),
110
+ ).toEqual([])
111
+ })
112
+ })
113
+
114
+ describe('buildConnectionSourceChoices', () => {
115
+ const sources = [
116
+ { source: 'github', label: 'GitHub', icon: 'i-lucide-github' },
117
+ { source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' },
118
+ ]
119
+ const isConnected = (source: string) => source === 'github'
120
+ const opts = { isConnected, canConnect: true, available: true, selected: 'github' }
121
+
122
+ it('offers the connected source and the rest as something to add', () => {
123
+ expect(buildConnectionSourceChoices(sources, opts)).toEqual([
124
+ [
125
+ {
126
+ action: 'select',
127
+ source: 'github',
128
+ label: 'GitHub',
129
+ icon: 'i-lucide-github',
130
+ active: true,
131
+ },
132
+ ],
133
+ [
134
+ {
135
+ action: 'connect',
136
+ source: 'confluence',
137
+ label: 'Confluence',
138
+ icon: 'i-lucide-file-text',
139
+ },
140
+ ],
141
+ ])
142
+ })
143
+
144
+ // No per-workspace toggle exists for these sources, so `enable` ("connected but switched off
145
+ // here") is a state they cannot be in. It is unrepresentable in the return TYPE, which is what
146
+ // stops a document surface wording an add entry as "Connect X" for a source already connected;
147
+ // this asserts the runtime half of that.
148
+ it('never words a document source as `enable`', () => {
149
+ const choices = buildConnectionSourceChoices(sources, {
150
+ ...opts,
151
+ isConnected: () => false,
152
+ selected: undefined,
153
+ })
154
+ expect(
155
+ choices
156
+ .flat()
157
+ .map((c) => c.action)
158
+ .every((action) => action === 'connect'),
159
+ ).toBe(true)
160
+ })
161
+
162
+ it('yields the connected tier alone for a member, with no add group', () => {
163
+ const choices = buildConnectionSourceChoices(sources, { ...opts, canConnect: false })
164
+ expect(choices).toHaveLength(1)
165
+ expect(choices[0]!.map((c) => c.source)).toEqual(['github'])
166
+ })
167
+ })
168
+
169
+ describe('menuIsPickable', () => {
170
+ // The papercut this exists for, and the one a member hits: a chevron opening a menu whose single
171
+ // entry re-selects what is already selected promises a choice that isn't there.
172
+ it('reports nothing to decide for a lone entry', () => {
173
+ expect(menuIsPickable([[{}]])).toBe(false)
174
+ expect(menuIsPickable([])).toBe(false)
175
+ })
176
+
177
+ it('counts across groups, so one source plus one to add IS a choice', () => {
178
+ expect(menuIsPickable([[{}], [{}]])).toBe(true)
179
+ expect(menuIsPickable([[{}, {}]])).toBe(true)
180
+ })
181
+ })
182
+
183
+ describe('sourceMenuItems', () => {
184
+ const onSelect = vi.fn()
185
+ const onAdd = vi.fn()
186
+
187
+ it('marks the selected source as CHECKED, not merely glyphed', () => {
188
+ const [offered] = sourceMenuItems(
189
+ buildSourceChoices([state('github'), state('jira')], 'jira'),
190
+ {
191
+ onSelect,
192
+ onAdd,
193
+ addLabel: { connect: (l) => `connect ${l}`, enable: (l) => `enable ${l}` },
194
+ },
195
+ )
196
+ expect(offered!.map((i) => [i.label, i.type, i.checked])).toEqual([
197
+ ['GITHUB', 'checkbox', false],
198
+ ['JIRA', 'checkbox', true],
199
+ ])
200
+ })
201
+
202
+ it('words each add entry for its own action and marks it with the plug icon', () => {
203
+ const [, addable] = sourceMenuItems(
204
+ buildSourceChoices(
205
+ [state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
206
+ 'github',
207
+ ),
208
+ {
209
+ onSelect,
210
+ onAdd,
211
+ addLabel: { connect: (l) => `connect ${l}`, enable: (l) => `enable ${l}` },
212
+ },
213
+ )
214
+ expect(addable!.map((i) => [i.label, i.icon])).toEqual([
215
+ ['connect JIRA', 'i-lucide-plug'],
216
+ ['enable LINEAR', 'i-lucide-plug'],
217
+ ])
218
+ })
219
+
220
+ // A connection-only menu owes ONE wording, and the type says so: passing `enable` here would not
221
+ // compile. That is the guard, and this pins the behaviour it guards.
222
+ it('takes only the `connect` wording for a connection-only menu', () => {
223
+ const items = sourceMenuItems(
224
+ buildConnectionSourceChoices(
225
+ [{ source: 'confluence', label: 'Confluence', icon: 'i-lucide-file-text' }],
226
+ { isConnected: () => false, canConnect: true, available: true, selected: undefined },
227
+ ),
228
+ { onSelect, onAdd, addLabel: { connect: (l) => `Connect ${l}` } },
229
+ )
230
+ expect(items.flat().map((i) => i.label)).toEqual(['Connect Confluence'])
231
+ })
232
+ })
233
+
234
+ describe('reconcileSource', () => {
235
+ it('selects the tracker the user just went off to connect, once it is offered', () => {
236
+ expect(reconcileSource(['github', 'jira'], 'github', 'jira')).toBe('jira')
237
+ })
238
+
239
+ it('keeps the current selection while the connect is still pending', () => {
240
+ expect(reconcileSource(['github'], 'github', 'jira')).toBe('github')
241
+ })
242
+
243
+ it('keeps a still-offered selection', () => {
244
+ expect(reconcileSource(['github', 'jira'], 'jira', null)).toBe('jira')
245
+ })
246
+
247
+ it('falls back to the first offered tracker when the selection stopped being offered', () => {
248
+ expect(reconcileSource(['github'], 'jira', null)).toBe('github')
249
+ })
250
+
251
+ it('selects the first offered tracker when nothing is selected yet', () => {
252
+ expect(reconcileSource(['github'], undefined, null)).toBe('github')
253
+ })
254
+
255
+ it('resolves to nothing when the workspace offers no tracker at all', () => {
256
+ expect(reconcileSource([], 'jira', 'jira')).toBeUndefined()
257
+ })
258
+ })
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Pure source-selection logic shared by every surface that picks an integration source: which
3
+ * one is selected, what its menu offers, and how that menu renders.
4
+ *
5
+ * The menu is deliberately two-tier (pick a source the workspace already has, or go and add one)
6
+ * because a source-picking surface is exactly where a user discovers the source they want is
7
+ * missing, and sending them off to the Integrations hub loses whatever they had in progress.
8
+ * Today `<ContextIssuePicker>` (attach a context issue) and `<BugHuntModal>` (scan a board for
9
+ * bugs) render it over TRACKERS, and `<ContextDocumentPicker>` (attach a context document) over
10
+ * DOCUMENT sources.
11
+ *
12
+ * The two integrations describe availability differently, so each gets its own CHOICE builder and
13
+ * they share one RENDERER. A tracker carries `available` plus a per-workspace `enabled` toggle, so
14
+ * it can be connected-but-off and its add entry has to say "enable" rather than "connect"; a
15
+ * document source is either connected or not, so `buildConnectionSourceChoices` cannot produce an
16
+ * `enable` choice at all. That is a fact about its RETURN TYPE rather than a convention, which is
17
+ * why these are two builders instead of one taking a flag: `sourceMenuItems` derives its wording
18
+ * map from what the choices can actually carry, so a document surface is not asked for wording it
19
+ * can never use, and a document source that one day GAINS a toggle fails the typecheck at every
20
+ * surface that renders it.
21
+ */
22
+ import type { DropdownMenuItem } from '@nuxt/ui'
23
+
24
+ /** What a menu needs to know about one configured source, whatever integration it belongs to. */
25
+ export interface SourceAvailability<S extends string> {
26
+ source: S
27
+ label: string
28
+ icon: string
29
+ /** A credential / installation is in place, so the source can be read right now. */
30
+ available: boolean
31
+ /**
32
+ * The workspace offers it. A tracker carries a per-workspace toggle that can be off while its
33
+ * credential is in place; an integration with no such toggle passes `true`.
34
+ */
35
+ enabled: boolean
36
+ }
37
+
38
+ /** A source whose only state is "connected or not", with no per-workspace toggle behind it. */
39
+ export interface ConnectableSource<S extends string> {
40
+ source: S
41
+ label: string
42
+ icon: string
43
+ }
44
+
45
+ /** The two ways a source that is not on offer yet can be added from a menu. */
46
+ export type AddAction = 'connect' | 'enable'
47
+
48
+ /** A source the surface can use right now. */
49
+ interface SelectChoice<S extends string> {
50
+ action: 'select'
51
+ source: S
52
+ label: string
53
+ icon: string
54
+ active: boolean
55
+ }
56
+
57
+ /**
58
+ * A configured source that is not offered yet, so it can be added from here. `connect` has no
59
+ * credential/App behind it; `enable` is connected but toggled off for the workspace. Both open the
60
+ * same connect modal, which serves either case: only the wording differs, so the user isn't told
61
+ * to "connect" something already connected.
62
+ */
63
+ interface AddChoice<S extends string, A extends AddAction> {
64
+ action: A
65
+ source: S
66
+ label: string
67
+ icon: string
68
+ }
69
+
70
+ /** One row of a source menu over an integration that has a per-workspace enable toggle. */
71
+ export type SourceChoice<S extends string> = SelectChoice<S> | AddChoice<S, AddAction>
72
+
73
+ /** One row of a source menu over an integration that is simply connected or not. */
74
+ export type ConnectionSourceChoice<S extends string> = SelectChoice<S> | AddChoice<S, 'connect'>
75
+
76
+ /**
77
+ * A source menu, as non-empty groups (the sources on offer, then the ones the user could add).
78
+ * Empty groups are dropped so the menu never renders a stray separator.
79
+ */
80
+ export function buildSourceChoices<S extends string>(
81
+ sources: readonly SourceAvailability<S>[],
82
+ selected: S | undefined,
83
+ ): SourceChoice<S>[][] {
84
+ const offered: SourceChoice<S>[] = []
85
+ const addable: SourceChoice<S>[] = []
86
+ for (const s of sources) {
87
+ if (s.available && s.enabled) {
88
+ offered.push({
89
+ action: 'select',
90
+ source: s.source,
91
+ label: s.label,
92
+ icon: s.icon,
93
+ active: s.source === selected,
94
+ })
95
+ } else {
96
+ addable.push({
97
+ action: s.available ? 'enable' : 'connect',
98
+ source: s.source,
99
+ label: s.label,
100
+ icon: s.icon,
101
+ })
102
+ }
103
+ }
104
+ return [offered, addable].filter((group) => group.length > 0)
105
+ }
106
+
107
+ /**
108
+ * The sources the acting user could add right now: those the workspace has not connected, and
109
+ * NONE when the integration is unavailable to this deployment or the user may not connect one.
110
+ *
111
+ * The single answer to "which document sources could I connect", so the picker's add tier and the
112
+ * hosts' connect shortcuts cannot drift. Both terms matter and neither implies the other: an
113
+ * unavailable integration has nothing to connect TO, and connecting stores a workspace credential,
114
+ * which is admin-tier while ATTACHING what it holds is member-tier. Offering a member an add entry
115
+ * would open a connect modal, take a token and 403, so what they see is what they can use.
116
+ *
117
+ * `available` is the store's probe result, so `null` (not probed yet) offers nothing: we do not know
118
+ * that there is anything to connect to, and a menu entry is a claim that there is.
119
+ */
120
+ export function connectableSources<S extends string, T extends { source: S }>(
121
+ sources: readonly T[],
122
+ opts: { isConnected: (source: S) => boolean; canConnect: boolean; available: boolean | null },
123
+ ): T[] {
124
+ if (opts.available !== true || !opts.canConnect) return []
125
+ return sources.filter((s) => !opts.isConnected(s.source))
126
+ }
127
+
128
+ /**
129
+ * A source menu for an integration that is either connected or not (document sources). Same two
130
+ * groups as {@link buildSourceChoices}, but the add tier can only ever be `connect`.
131
+ */
132
+ export function buildConnectionSourceChoices<S extends string>(
133
+ sources: readonly ConnectableSource<S>[],
134
+ opts: {
135
+ isConnected: (source: S) => boolean
136
+ canConnect: boolean
137
+ available: boolean | null
138
+ selected: S | undefined
139
+ },
140
+ ): ConnectionSourceChoice<S>[][] {
141
+ const connected: ConnectionSourceChoice<S>[] = sources
142
+ .filter((s) => opts.isConnected(s.source))
143
+ .map((s) => ({
144
+ action: 'select',
145
+ source: s.source,
146
+ label: s.label,
147
+ icon: s.icon,
148
+ active: s.source === opts.selected,
149
+ }))
150
+ const addable: ConnectionSourceChoice<S>[] = connectableSources(sources, opts).map((s) => ({
151
+ action: 'connect',
152
+ source: s.source,
153
+ label: s.label,
154
+ icon: s.icon,
155
+ }))
156
+ return [connected, addable].filter((group) => group.length > 0)
157
+ }
158
+
159
+ /**
160
+ * Wording for each way a source can be ADDED, as an exhaustive `Record` over exactly the add
161
+ * actions the menu's own choices can carry. A tracker surface owes both spellings; a document
162
+ * surface owes only `connect`, and gains a typecheck failure rather than the wrong wording if that
163
+ * ever stops being true.
164
+ */
165
+ export type AddSourceLabels<A extends AddAction> = Record<A, (label: string) => string>
166
+
167
+ /**
168
+ * Render source choices as dropdown items. The ONE place the two-tier menu's presentation lives:
169
+ * the selected source is a CHECKED item rather than one carrying a decorative glyph, so a screen
170
+ * reader announces which source is in use instead of just naming it, and every add entry carries
171
+ * the plug icon that distinguishes "go and set this up" from "use this".
172
+ */
173
+ export function sourceMenuItems<S extends string, A extends AddAction>(
174
+ groups: readonly (readonly (SelectChoice<S> | AddChoice<S, A>)[])[],
175
+ opts: {
176
+ onSelect: (source: S) => void
177
+ onAdd: (source: S) => void
178
+ addLabel: AddSourceLabels<A>
179
+ },
180
+ ): DropdownMenuItem[][] {
181
+ return groups.map((group) =>
182
+ group.map((choice) =>
183
+ isAddChoice(choice)
184
+ ? {
185
+ label: opts.addLabel[choice.action](choice.label),
186
+ icon: 'i-lucide-plug',
187
+ onSelect: () => opts.onAdd(choice.source),
188
+ }
189
+ : {
190
+ label: choice.label,
191
+ icon: choice.icon,
192
+ type: 'checkbox' as const,
193
+ checked: choice.active,
194
+ onSelect: () => opts.onSelect(choice.source),
195
+ },
196
+ ),
197
+ )
198
+ }
199
+
200
+ /**
201
+ * Whether a choice is an ADD entry. A hand-written predicate because the add member's `action` is
202
+ * the type parameter `A` rather than a literal, and TypeScript will not narrow a generic discriminant
203
+ * on its own: without this, reading `.active` off the select half fails to compile.
204
+ */
205
+ function isAddChoice<S extends string, A extends AddAction>(
206
+ choice: SelectChoice<S> | AddChoice<S, A>,
207
+ ): choice is AddChoice<S, A> {
208
+ return choice.action !== 'select'
209
+ }
210
+
211
+ /**
212
+ * The ADD half of a menu's choices, flattened: what a surface renders as buttons when nothing is
213
+ * offered yet and there is no selection to make. Narrowed here rather than at each call site so the
214
+ * wording map stays exhaustive over what actually arrives, and so a `select` choice cannot leak into
215
+ * a row that offers to connect a source the workspace already has.
216
+ */
217
+ export function addChoicesOf<S extends string, A extends AddAction>(
218
+ groups: readonly (readonly (SelectChoice<S> | AddChoice<S, A>)[])[],
219
+ ): AddChoice<S, A>[] {
220
+ return groups.flat().filter((choice) => isAddChoice(choice))
221
+ }
222
+
223
+ /**
224
+ * Whether a source menu has anything to decide. With a single entry the trigger can only re-pick
225
+ * what is already selected, so a surface names the source as a LABEL instead: a chevron opening a
226
+ * one-item menu promises a choice that isn't there. The state is reached most often by a member,
227
+ * whose add tier is withheld (see {@link connectableSources}), which is exactly the reader least
228
+ * able to tell a dead control from a broken one.
229
+ */
230
+ export function menuIsPickable(groups: readonly (readonly unknown[])[]): boolean {
231
+ return groups.reduce((total, group) => total + group.length, 0) > 1
232
+ }
233
+
234
+ /**
235
+ * The source a surface should hold once the offered set changes: after a connect, a disconnect, or
236
+ * the per-workspace toggle flipping elsewhere.
237
+ *
238
+ * `awaiting` is the source the user just left to connect: the moment it becomes offered it wins, so
239
+ * they land back on the source they went to add rather than on whatever was selected before.
240
+ * Otherwise a still-offered selection is kept, and a selection that stopped being offered falls
241
+ * back to the first one (reading a source the workspace no longer offers only yields errors).
242
+ */
243
+ export function reconcileSource<S extends string>(
244
+ offered: readonly S[],
245
+ selected: S | undefined,
246
+ awaiting: S | null,
247
+ ): S | undefined {
248
+ if (awaiting && offered.includes(awaiting)) return awaiting
249
+ if (selected && offered.includes(selected)) return selected
250
+ return offered[0]
251
+ }