@jkwd/inbase 0.1.21 → 0.1.23

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.
Files changed (58) hide show
  1. package/README.md +13 -7
  2. package/apps/explorer/package.json +1 -0
  3. package/apps/explorer/scripts/explain-store.d.ts +135 -0
  4. package/apps/explorer/scripts/explain-store.mjs +666 -0
  5. package/apps/explorer/scripts/patch-lib.mjs +4 -0
  6. package/apps/explorer/scripts/scan-target.mjs +22 -5
  7. package/apps/explorer/scripts/session-store.d.ts +86 -11
  8. package/apps/explorer/scripts/session-store.mjs +371 -58
  9. package/apps/explorer/scripts/target-config.d.ts +38 -3
  10. package/apps/explorer/scripts/target-config.mjs +147 -3
  11. package/apps/explorer/src/App.tsx +1073 -158
  12. package/apps/explorer/src/agentIntent.ts +61 -7
  13. package/apps/explorer/src/codebase.ts +1 -1
  14. package/apps/explorer/src/devTargets.ts +66 -0
  15. package/apps/explorer/src/explain.ts +312 -0
  16. package/apps/explorer/src/index.css +874 -222
  17. package/apps/explorer/src/layout.ts +55 -0
  18. package/apps/explorer/src/scene/DistantFileBlocks.tsx +4 -2
  19. package/apps/explorer/src/scene/FileBlock.tsx +95 -72
  20. package/apps/explorer/src/scene/FolderArea.tsx +55 -32
  21. package/apps/explorer/src/scene/MapView.tsx +506 -33
  22. package/apps/explorer/src/scene/RelationLines.tsx +7 -0
  23. package/apps/explorer/src/scene/World.tsx +146 -32
  24. package/apps/explorer/src/speech.ts +228 -0
  25. package/apps/explorer/src/theme.ts +29 -0
  26. package/apps/explorer/src/types.ts +98 -8
  27. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +1 -1
  28. package/apps/explorer/src/ui/ExplainAskCard.tsx +142 -0
  29. package/apps/explorer/src/ui/ExplainHud.tsx +524 -0
  30. package/apps/explorer/src/ui/ExplainInfoPanel.tsx +135 -0
  31. package/apps/explorer/src/ui/ExplainPointer.tsx +73 -0
  32. package/apps/explorer/src/ui/EyeIcon.tsx +38 -1
  33. package/apps/explorer/src/ui/HUD.tsx +1067 -795
  34. package/apps/explorer/src/ui/NameInput.tsx +114 -5
  35. package/apps/explorer/src/userContext.ts +0 -11
  36. package/apps/explorer/src/userCreated.ts +54 -1
  37. package/apps/explorer/vite.config.ts +173 -26
  38. package/bin/inbase.mjs +11 -2
  39. package/bin/project.mjs +6 -4
  40. package/bin/session.mjs +287 -38
  41. package/package.json +4 -1
  42. package/skill/commands/amber.md +23 -0
  43. package/skill/commands/blue.md +13 -0
  44. package/skill/commands/coral.md +23 -0
  45. package/skill/commands/explain.md +77 -0
  46. package/skill/commands/green.md +23 -0
  47. package/skill/commands/inbase.md +7 -5
  48. package/skill/commands/lime.md +23 -0
  49. package/skill/commands/orange.md +23 -0
  50. package/skill/commands/purple.md +23 -0
  51. package/skill/commands/red.md +23 -0
  52. package/skill/commands/skipinbase.md +1 -1
  53. package/skill/commands/violet.md +23 -0
  54. package/skill/commands/yellow.md +23 -0
  55. package/skill/inbase/SKILL.md +124 -76
  56. package/apps/explorer/src/scene/BlockPlacer.tsx +0 -78
  57. package/apps/explorer/src/scene/IslandPlacer.tsx +0 -31
  58. package/apps/explorer/src/scene/SelectionThumbnail.tsx +0 -1069
@@ -3,6 +3,7 @@ import type {
3
3
  AgentIntentBundle,
4
4
  BlueprintNote,
5
5
  BlueprintPointer,
6
+ LocalBlueprint,
6
7
  PatchImport,
7
8
  PatchImportAddition,
8
9
  PatchSymbolAddition,
@@ -10,6 +11,7 @@ import type {
10
11
  UserCreatedIsland,
11
12
  WorkflowAction,
12
13
  } from './types'
14
+ import { GLOBAL_BLUEPRINT_COLOR } from './types'
13
15
  import {
14
16
  parseBlueprintNotes,
15
17
  parseBlueprintPointers,
@@ -92,6 +94,9 @@ export const emptyIntent: AgentIntent = {
92
94
  showMap: false,
93
95
  status: 'idle',
94
96
  name: null,
97
+ color: null,
98
+ colorName: null,
99
+ colorHex: null,
95
100
  feature: null,
96
101
  steps: [],
97
102
  step: null,
@@ -121,7 +126,8 @@ export const emptyIntent: AgentIntent = {
121
126
  llmIdle: false,
122
127
  awaitingAttach: false,
123
128
  listening: false,
124
- lastAck: null,
129
+ lastAck: null,
130
+ pendingExplain: false,
125
131
  initialInstruction: null,
126
132
  contextFiles: [],
127
133
  creationMode: false,
@@ -129,6 +135,7 @@ export const emptyIntent: AgentIntent = {
129
135
  blueprintHidden: false,
130
136
  blueprintRevision: 0,
131
137
  blueprintSessionId: null,
138
+ localBlueprintEnabled: false,
132
139
  userCreatedBlocks: [],
133
140
  userCreatedIslands: [],
134
141
  blueprintFunctions: [],
@@ -144,6 +151,9 @@ function normalize(data: Partial<AgentIntent> | null | undefined): AgentIntent {
144
151
  showMap: Boolean(data?.showMap),
145
152
  status: data?.status ?? 'idle',
146
153
  name: data?.name ?? null,
154
+ color: typeof data?.color === 'string' ? data.color : null,
155
+ colorName: typeof data?.colorName === 'string' ? data.colorName : null,
156
+ colorHex: typeof data?.colorHex === 'string' ? data.colorHex : null,
147
157
  feature: data?.feature ?? null,
148
158
  steps: Array.isArray(data?.steps) ? data.steps : [],
149
159
  step: typeof data?.step === 'number' ? data.step : null,
@@ -177,6 +187,7 @@ function normalize(data: Partial<AgentIntent> | null | undefined): AgentIntent {
177
187
  awaitingAttach: Boolean(data?.awaitingAttach),
178
188
  listening: Boolean(data?.listening),
179
189
  lastAck: normalizeAck(data?.lastAck),
190
+ pendingExplain: Boolean(data?.pendingExplain),
180
191
  initialInstruction:
181
192
  typeof data?.initialInstruction === 'string' ? data.initialInstruction : null,
182
193
  contextFiles: normalizeContextFiles(data?.contextFiles),
@@ -189,6 +200,7 @@ function normalize(data: Partial<AgentIntent> | null | undefined): AgentIntent {
189
200
  typeof data?.blueprintSessionId === 'string'
190
201
  ? data.blueprintSessionId
191
202
  : null,
203
+ localBlueprintEnabled: Boolean(data?.localBlueprintEnabled),
192
204
  userCreatedBlocks: parseUserCreatedBlocks(data?.userCreatedBlocks),
193
205
  userCreatedIslands: parseUserCreatedIslands(data?.userCreatedIslands),
194
206
  blueprintFunctions: normalizeSymbolAdditions(data?.blueprintFunctions),
@@ -214,6 +226,28 @@ function normalizeBlueprint(data: Partial<AgentIntentBundle['blueprint']> | null
214
226
  }
215
227
  }
216
228
 
229
+ function normalizeLocalBlueprints(value: unknown): LocalBlueprint[] {
230
+ if (!Array.isArray(value)) return []
231
+ return value.flatMap((item) => {
232
+ if (!item || typeof item !== 'object') return []
233
+ const row = item as Partial<LocalBlueprint>
234
+ const color = typeof row.color === 'string' ? row.color : ''
235
+ const colorName = typeof row.colorName === 'string' ? row.colorName : ''
236
+ const colorHex = typeof row.colorHex === 'string' ? row.colorHex : ''
237
+ const sessionId = typeof row.sessionId === 'string' ? row.sessionId : ''
238
+ if (!color || !sessionId) return []
239
+ return [
240
+ {
241
+ ...normalizeBlueprint(row),
242
+ color,
243
+ colorName,
244
+ colorHex,
245
+ sessionId,
246
+ },
247
+ ]
248
+ })
249
+ }
250
+
217
251
  export async function fetchAgentIntents(): Promise<AgentIntentBundle> {
218
252
  const query = new URLSearchParams({ t: String(Date.now()) })
219
253
  const response = await fetch(`/api/agent-intent?${query}`)
@@ -224,6 +258,7 @@ export async function fetchAgentIntents(): Promise<AgentIntentBundle> {
224
258
  nextAttachSessionId: null,
225
259
  intents: [],
226
260
  blueprint: emptyBlueprint,
261
+ localBlueprints: [],
227
262
  }
228
263
  }
229
264
  const data = (await response.json()) as {
@@ -232,6 +267,7 @@ export async function fetchAgentIntents(): Promise<AgentIntentBundle> {
232
267
  intents?: unknown
233
268
  sessionId?: string | null
234
269
  blueprint?: Partial<AgentIntentBundle['blueprint']>
270
+ localBlueprints?: unknown
235
271
  } & Partial<AgentIntent>
236
272
  if (Array.isArray(data.intents)) {
237
273
  return {
@@ -245,6 +281,7 @@ export async function fetchAgentIntents(): Promise<AgentIntentBundle> {
245
281
  .map((intent) => normalize(intent as Partial<AgentIntent>))
246
282
  .filter((intent) => Boolean(intent.sessionId)),
247
283
  blueprint: normalizeBlueprint(data.blueprint),
284
+ localBlueprints: normalizeLocalBlueprints(data.localBlueprints),
248
285
  }
249
286
  }
250
287
  const intent = normalize(data)
@@ -252,6 +289,7 @@ export async function fetchAgentIntents(): Promise<AgentIntentBundle> {
252
289
  focusedSessionId: intent.sessionId,
253
290
  nextAttachSessionId: intent.awaitingAttach ? intent.sessionId : null,
254
291
  intents: intent.sessionId ? [intent] : [],
292
+ localBlueprints: [],
255
293
  blueprint: normalizeBlueprint(data.blueprint ?? {
256
294
  hidden: intent.blueprintHidden,
257
295
  revision: intent.blueprintRevision,
@@ -320,6 +358,7 @@ export async function performAgentAction(
320
358
  export function persistSessionBlueprint(
321
359
  sessionId: string | null | undefined,
322
360
  payload: {
361
+ color?: string | null
323
362
  userCreatedBlocks: UserCreatedBlock[]
324
363
  userCreatedIslands: UserCreatedIsland[]
325
364
  addedFunctions?: PatchSymbolAddition[]
@@ -336,36 +375,51 @@ export function persistSessionBlueprint(
336
375
  action: 'blueprint_update',
337
376
  sessionId,
338
377
  ...payload,
378
+ color: payload.color ?? GLOBAL_BLUEPRINT_COLOR.id,
339
379
  }),
340
380
  }).catch(() => {
341
- // Keep local drafts if the visualizer could not save the shared blueprint.
381
+ // Keep local drafts if the visualizer could not save the blueprint.
342
382
  })
343
383
  }
344
384
 
345
- export function persistBlueprintHidden(hidden: boolean) {
385
+ export function persistBlueprintHidden(
386
+ hidden: boolean,
387
+ color: string | null | undefined = GLOBAL_BLUEPRINT_COLOR.id,
388
+ ) {
346
389
  return fetch('/api/agent-intent', {
347
390
  method: 'POST',
348
391
  headers: { 'Content-Type': 'application/json' },
349
392
  body: JSON.stringify({
350
393
  action: 'blueprint_set_hidden',
351
394
  hidden,
395
+ color: color ?? GLOBAL_BLUEPRINT_COLOR.id,
352
396
  }),
353
397
  })
354
398
  }
355
399
 
356
- export function persistBlueprintClear() {
400
+ export function persistBlueprintClear(
401
+ color: string | null | undefined = GLOBAL_BLUEPRINT_COLOR.id,
402
+ ) {
357
403
  return fetch('/api/agent-intent', {
358
404
  method: 'POST',
359
405
  headers: { 'Content-Type': 'application/json' },
360
- body: JSON.stringify({ action: 'blueprint_clear' }),
406
+ body: JSON.stringify({
407
+ action: 'blueprint_clear',
408
+ color: color ?? GLOBAL_BLUEPRINT_COLOR.id,
409
+ }),
361
410
  })
362
411
  }
363
412
 
364
- export function persistBlueprintCleanup() {
413
+ export function persistBlueprintCleanup(
414
+ color: string | null | undefined = GLOBAL_BLUEPRINT_COLOR.id,
415
+ ) {
365
416
  return fetch('/api/agent-intent', {
366
417
  method: 'POST',
367
418
  headers: { 'Content-Type': 'application/json' },
368
- body: JSON.stringify({ action: 'blueprint_cleanup' }),
419
+ body: JSON.stringify({
420
+ action: 'blueprint_cleanup',
421
+ color: color ?? GLOBAL_BLUEPRINT_COLOR.id,
422
+ }),
369
423
  })
370
424
  }
371
425
 
@@ -1,6 +1,6 @@
1
1
  import type { CodebaseGraph } from './types'
2
2
 
3
- function parseCodebase(data: unknown): CodebaseGraph | null {
3
+ export function parseCodebase(data: unknown): CodebaseGraph | null {
4
4
  if (
5
5
  !data ||
6
6
  typeof data !== 'object' ||
@@ -0,0 +1,66 @@
1
+ import { parseCodebase } from './codebase'
2
+ import type { CodebaseGraph } from './types'
3
+
4
+ export type DevTargetOption = {
5
+ id: string
6
+ label: string
7
+ }
8
+
9
+ export type DevTargetsState = {
10
+ enabled: boolean
11
+ currentId: string | null
12
+ targets: DevTargetOption[]
13
+ }
14
+
15
+ export const emptyDevTargets: DevTargetsState = {
16
+ enabled: false,
17
+ currentId: null,
18
+ targets: [],
19
+ }
20
+
21
+ function parseState(data: unknown): DevTargetsState {
22
+ if (!data || typeof data !== 'object') return emptyDevTargets
23
+ const record = data as DevTargetsState
24
+ if (!record.enabled || !Array.isArray(record.targets)) return emptyDevTargets
25
+ return {
26
+ enabled: true,
27
+ currentId: typeof record.currentId === 'string' ? record.currentId : null,
28
+ targets: record.targets.filter(
29
+ (item): item is DevTargetOption =>
30
+ Boolean(item) &&
31
+ typeof item.id === 'string' &&
32
+ typeof item.label === 'string',
33
+ ),
34
+ }
35
+ }
36
+
37
+ export async function fetchDevTargets(): Promise<DevTargetsState> {
38
+ try {
39
+ const response = await fetch('/api/dev-targets')
40
+ if (!response.ok) return emptyDevTargets
41
+ return parseState(await response.json())
42
+ } catch {
43
+ return emptyDevTargets
44
+ }
45
+ }
46
+
47
+ export async function selectDevTarget(id: string): Promise<{
48
+ state: DevTargetsState
49
+ graph: CodebaseGraph | null
50
+ } | null> {
51
+ try {
52
+ const response = await fetch('/api/dev-targets', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ id }),
56
+ })
57
+ if (!response.ok) return null
58
+ const payload = (await response.json()) as { codebase?: unknown }
59
+ return {
60
+ state: parseState(payload),
61
+ graph: parseCodebase(payload.codebase),
62
+ }
63
+ } catch {
64
+ return null
65
+ }
66
+ }
@@ -0,0 +1,312 @@
1
+ import { folderOfFile } from './layout'
2
+ import type {
3
+ CodebaseGraph,
4
+ ExplainSession,
5
+ ExplainStep,
6
+ ExplainSymbolRef,
7
+ ExplainTargetKind,
8
+ FileNode,
9
+ PatchImport,
10
+ } from './types'
11
+
12
+ export function emptyExplain(): ExplainSession {
13
+ return {
14
+ active: false,
15
+ question: '',
16
+ steps: [],
17
+ currentStep: '1',
18
+ pendingQuestion: null,
19
+ pendingStart: null,
20
+ answering: false,
21
+ presentation: 'walk',
22
+ updatedAt: null,
23
+ }
24
+ }
25
+
26
+ export function explainIsCard(explain: ExplainSession) {
27
+ return explain.active && explain.presentation === 'card'
28
+ }
29
+
30
+ export async function fetchExplain(): Promise<ExplainSession> {
31
+ try {
32
+ const query = new URLSearchParams({ t: String(Date.now()) })
33
+ const response = await fetch(`/api/explain?${query}`)
34
+ if (!response.ok) return emptyExplain()
35
+ return (await response.json()) as ExplainSession
36
+ } catch {
37
+ return emptyExplain()
38
+ }
39
+ }
40
+
41
+ export function persistExplainStep(step: string) {
42
+ return fetch('/api/explain', {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ action: 'set_step', step }),
46
+ }).catch(() => {
47
+ // Keep the local step if the visualizer could not save it.
48
+ })
49
+ }
50
+
51
+ export function persistExplainAsk(step: string, question: string) {
52
+ return fetch('/api/explain', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ action: 'ask', step, question }),
56
+ }).catch(() => {
57
+ // Keep the local question if the visualizer could not save it.
58
+ })
59
+ }
60
+
61
+ export function persistExplainStop() {
62
+ return fetch('/api/explain', {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ action: 'stop' }),
66
+ }).catch(() => {
67
+ // Keep the local overlay if the visualizer could not stop.
68
+ })
69
+ }
70
+
71
+ export function explainTargetLabel(input: {
72
+ kind: ExplainTargetKind
73
+ path: string
74
+ name?: string
75
+ }) {
76
+ const symbol = input.name?.trim() ?? ''
77
+ return symbol
78
+ ? `${input.kind} ${symbol} in ${input.path}`
79
+ : `${input.kind} ${input.path}`
80
+ }
81
+
82
+ export function explainTargetQuestion(
83
+ kind: ExplainTargetKind,
84
+ path: string,
85
+ name?: string,
86
+ ) {
87
+ return `Explain the function of the ${explainTargetLabel({ kind, path, name })} and where it fits in the codebase.`
88
+ }
89
+
90
+ export function persistExplainStart(input: {
91
+ kind: ExplainTargetKind
92
+ path: string
93
+ name?: string
94
+ sessionId?: string | null
95
+ }) {
96
+ return fetch('/api/explain', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ action: 'start', ...input }),
100
+ }).catch(() => {
101
+ // Keep the local overlay if the visualizer could not start.
102
+ })
103
+ }
104
+
105
+ export function currentExplainStep(explain: ExplainSession): ExplainStep | null {
106
+ if (!explain.active || explain.steps.length === 0) return null
107
+ return (
108
+ explain.steps.find((step) => step.index === explain.currentStep) ??
109
+ explain.steps[0] ??
110
+ null
111
+ )
112
+ }
113
+
114
+ export function explainStepPosition(explain: ExplainSession, id: string) {
115
+ return explain.steps.findIndex((step) => step.index === id)
116
+ }
117
+
118
+ export function explainNeighbor(
119
+ explain: ExplainSession,
120
+ id: string,
121
+ delta: number,
122
+ ) {
123
+ const position = explainStepPosition(explain, id)
124
+ if (position < 0) return null
125
+ return explain.steps[position + delta]?.index ?? null
126
+ }
127
+
128
+ export function explainStepDepth(id: string) {
129
+ return String(id).split('.').length - 1
130
+ }
131
+
132
+ export function topLevelExplainStepId(id: string) {
133
+ const next = String(id)
134
+ const dot = next.indexOf('.')
135
+ return dot === -1 ? next : next.slice(0, dot)
136
+ }
137
+
138
+ export function stripExplainSubSteps(steps: ExplainStep[]): ExplainStep[] {
139
+ return steps
140
+ .filter((step) => !step.index.includes('.'))
141
+ .map((step) => ({ ...step, asked: '' }))
142
+ }
143
+
144
+ export function explainIsPreparing(explain: ExplainSession) {
145
+ if (!explain.active) return false
146
+ if (explain.steps.length === 0) return true
147
+ return Boolean(explain.pendingQuestion) || explain.answering
148
+ }
149
+
150
+ export function explainSpeechText(step: Pick<ExplainStep, 'title' | 'body'> | null) {
151
+ if (!step) return ''
152
+ return [step.title, step.body]
153
+ .map((part) => part.trim())
154
+ .filter(Boolean)
155
+ .join('. ')
156
+ }
157
+
158
+ export function explainCardCopy(explain: ExplainSession) {
159
+ const first =
160
+ explain.steps.find((step) => !step.index.includes('.') && step.body.trim()) ??
161
+ explain.steps.find((step) => !step.index.includes('.')) ??
162
+ null
163
+ const pending = explain.pendingStart
164
+ const title = pending
165
+ ? pending.name?.trim() || pending.path.split('/').pop() || pending.path
166
+ : first?.title.trim() || 'Explanation'
167
+ return {
168
+ title,
169
+ body: first?.body.trim() ?? '',
170
+ ready: Boolean(first?.body.trim()),
171
+ }
172
+ }
173
+
174
+ export function explainSymbolKey(ref: ExplainSymbolRef) {
175
+ return `${ref.kind}:${ref.name}`
176
+ }
177
+
178
+ export function explainMatchesSymbol(
179
+ ref: ExplainSymbolRef | null | undefined,
180
+ kind: FileNode['symbols'][number]['kind'] | 'file',
181
+ name: string,
182
+ ) {
183
+ if (!ref) return false
184
+ if (ref.kind === 'file') return kind === 'file'
185
+ if (ref.kind === 'symbol') return kind !== 'file' && ref.name === name
186
+ return ref.kind === kind && ref.name === name
187
+ }
188
+
189
+ export function explainHitsSymbol(
190
+ refs: ExplainSymbolRef[],
191
+ kind: FileNode['symbols'][number]['kind'] | 'file',
192
+ name: string,
193
+ ) {
194
+ return refs.some((ref) => explainMatchesSymbol(ref, kind, name))
195
+ }
196
+
197
+ export function explainInfoFile(
198
+ step: ExplainStep | null,
199
+ graph: CodebaseGraph,
200
+ ): FileNode | null {
201
+ if (!step?.info || !step.select) return null
202
+ return (
203
+ graph.files.find((file) => file.id === step.select) ?? {
204
+ id: step.select,
205
+ name: step.select.split('/').pop() || step.select,
206
+ path: step.select,
207
+ folder: '',
208
+ lines: 0,
209
+ language: '',
210
+ symbols: [],
211
+ imports: [],
212
+ }
213
+ )
214
+ }
215
+
216
+ export type ExplainFocus = {
217
+ files: Set<string>
218
+ folders: Set<string>
219
+ select: string | null
220
+ importedBy: boolean
221
+ relations: PatchImport[]
222
+ zoomFiles: string[]
223
+ zoomFolders: string[]
224
+ }
225
+
226
+ export function explainFocus(
227
+ step: ExplainStep | null,
228
+ graph: CodebaseGraph,
229
+ ): ExplainFocus | null {
230
+ if (!step) return null
231
+ const files = new Set(step.files)
232
+ const folders = new Set(step.folders)
233
+ if (step.select) files.add(step.select)
234
+ const relations: PatchImport[] = step.relations.map((edge) => ({
235
+ from: edge.from,
236
+ to: edge.to,
237
+ }))
238
+ for (const edge of relations) {
239
+ files.add(edge.from)
240
+ files.add(edge.to)
241
+ }
242
+ if (step.select) {
243
+ if (step.importedBy) {
244
+ for (const file of graph.files) {
245
+ if (file.imports.includes(step.select)) files.add(file.id)
246
+ }
247
+ } else {
248
+ const selected = graph.files.find((file) => file.id === step.select)
249
+ for (const id of selected?.imports ?? []) files.add(id)
250
+ }
251
+ }
252
+ for (const file of graph.files) {
253
+ if (folders.has(file.folder)) files.add(file.id)
254
+ }
255
+ for (const id of files) {
256
+ const folder = graph.files.find((file) => file.id === id)?.folder ?? folderOfFile(id)
257
+ if (folder) folders.add(folder)
258
+ }
259
+
260
+ const zoomFolders = step.zoom
261
+ ? graph.folders.some((folder) => folder.path === step.zoom)
262
+ ? [step.zoom]
263
+ : []
264
+ : [...step.folders]
265
+ const zoomFiles = step.zoom
266
+ ? graph.files.some((file) => file.id === step.zoom)
267
+ ? [step.zoom]
268
+ : []
269
+ : [...step.files]
270
+ if (step.zoom && zoomFolders.length === 0 && zoomFiles.length === 0) {
271
+ zoomFiles.push(step.zoom)
272
+ }
273
+
274
+ return {
275
+ files,
276
+ folders,
277
+ select: step.select,
278
+ importedBy: step.importedBy,
279
+ relations,
280
+ zoomFiles: zoomFiles.length > 0 ? zoomFiles : [...files],
281
+ zoomFolders: zoomFolders.length > 0 ? zoomFolders : [...folders],
282
+ }
283
+ }
284
+
285
+ export function explainHasFocus(focus: ExplainFocus | null) {
286
+ return Boolean(focus && (focus.files.size > 0 || focus.folders.size > 0))
287
+ }
288
+
289
+ export function explainFileFocused(
290
+ focus: ExplainFocus | null,
291
+ fileId: string,
292
+ _folder?: string,
293
+ ) {
294
+ if (!explainHasFocus(focus) || !focus) return true
295
+ return focus.files.has(fileId)
296
+ }
297
+
298
+ export function explainFileHighlighted(
299
+ focus: ExplainFocus | null,
300
+ fileId: string,
301
+ folder?: string,
302
+ ) {
303
+ return explainHasFocus(focus) && explainFileFocused(focus, fileId, folder)
304
+ }
305
+
306
+ export function explainFolderFocused(focus: ExplainFocus | null, folderPath: string) {
307
+ if (!explainHasFocus(focus) || !focus) return true
308
+ for (const id of focus.files) {
309
+ if (folderOfFile(id) === folderPath) return true
310
+ }
311
+ return false
312
+ }