@puredesktop/puredesktop-ui-bridge 2.1.6 → 2.1.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.
Files changed (64) hide show
  1. package/package.json +55 -1
  2. package/src/bridge/agentTypes.ts +21 -1
  3. package/src/bridge/collectionImagePaste.ts +19 -1
  4. package/src/bridge/context.mjs +32 -0
  5. package/src/bridge/documents.d.mts +76 -0
  6. package/src/bridge/documents.mjs +40 -0
  7. package/src/bridge/methods.d.mts +23 -0
  8. package/src/bridge/methods.mjs +23 -0
  9. package/src/bridge/pureRender/base.css +44 -0
  10. package/src/bridge/pureRender/document.ts +29 -2
  11. package/src/bridge/pureRender/index.ts +3 -0
  12. package/src/bridge/pureRender/normalize.ts +167 -0
  13. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  14. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  15. package/src/bridge/pureRender/styles.ts +125 -5
  16. package/src/bridge/pureRender/theme.test.ts +259 -0
  17. package/src/bridge/pureRender/theme.ts +19 -1
  18. package/src/bridge/pureRender/types.ts +33 -0
  19. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  20. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  21. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  22. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  23. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  24. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  25. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  26. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  27. package/src/bridge/storage.d.mts +3 -1
  28. package/src/bridge/storage.test.ts +2 -2
  29. package/src/bridge/types.ts +7 -0
  30. package/src/components/agents/AgentDrawerPanel.tsx +2 -0
  31. package/src/components/agents/AgentMessageList.tsx +6 -1
  32. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  33. package/src/components/agents/ContextPicker.tsx +239 -0
  34. package/src/components/agents/agentPanelStyles.ts +3 -4
  35. package/src/components/agents/agentTypes.ts +2 -0
  36. package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
  37. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  38. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  39. package/src/components/common/containers/AppFrame.tsx +10 -1
  40. package/src/components/common/containers/AppHeader.tsx +22 -0
  41. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  42. package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
  43. package/src/components/common/documents/index.ts +8 -0
  44. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  45. package/src/components/common/research/index.ts +2 -0
  46. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  47. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  48. package/src/components/print-design/index.ts +2 -0
  49. package/src/components/print-design/previewContent.test.ts +32 -0
  50. package/src/components/print-design/previewContent.ts +31 -0
  51. package/src/context/buildContextDocument.test.ts +240 -0
  52. package/src/context/buildContextDocument.ts +309 -0
  53. package/src/context/contextAccess.ts +66 -0
  54. package/src/context/contextConfig.ts +72 -0
  55. package/src/context/contextDocument.test.ts +155 -0
  56. package/src/context/contextDocument.ts +300 -0
  57. package/src/context/contextSections.test.ts +88 -0
  58. package/src/context/contextSections.ts +84 -0
  59. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  60. package/src/context/htmlToContextMarkdown.ts +369 -0
  61. package/src/theme/appAccents.test.ts +36 -35
  62. package/src/theme/appAccents.ts +87 -104
  63. package/src/theme/appIdentityCss.mjs +169 -231
  64. package/src/theme/appIdentityCss.ts +213 -233
@@ -0,0 +1,359 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ autosavePlatformDocument,
4
+ createPlatformDraft,
5
+ duplicatePlatformDocument,
6
+ promotePlatformDocument,
7
+ renamePlatformDocument,
8
+ touchPlatformRecentDocument,
9
+ type PlatformDocumentFilePayload,
10
+ } from '../documents.mjs'
11
+ import { bridge } from '../client.mjs'
12
+ import { PLATFORM_BRIDGE_METHODS } from '../methods.mjs'
13
+
14
+ export type DocumentLifecycleStatus = 'none' | 'draft' | 'filed'
15
+
16
+ export interface DocumentLifecycleDoc {
17
+ path: string | null
18
+ title: string
19
+ status: DocumentLifecycleStatus
20
+ savedAt: string | null
21
+ saving: boolean
22
+ error: string | null
23
+ }
24
+
25
+ export interface UseDocumentLifecycleOptions {
26
+ appSlug: string
27
+ /** Document suffix, e.g. `.research`. */
28
+ suffix: string
29
+ kind: 'package' | 'file'
30
+ /** Called at flush time; returns the document's current file contents. */
31
+ serialize: () => PlatformDocumentFilePayload[]
32
+ /** Prefill for the naming dialog (e.g. an AI-generated title). */
33
+ suggestedTitle?: string
34
+ debounceMs?: number
35
+ }
36
+
37
+ export interface DocumentLifecycle {
38
+ doc: DocumentLifecycleDoc
39
+ /** Create the durable draft if none exists yet; returns the path. */
40
+ ensureDraft: () => Promise<string | null>
41
+ /** Note a content change: lazily creates the draft, debounces an autosave. */
42
+ markDirty: () => void
43
+ /** Immediate save of the current serialize() output. */
44
+ flush: () => Promise<void>
45
+ /** Name + file the document (move out of drafts). */
46
+ promote: (title: string, targetDir?: string) => Promise<void>
47
+ rename: (title: string) => Promise<void>
48
+ duplicate: () => Promise<string | null>
49
+ revealInFinder: () => Promise<void>
50
+ openInFiles: () => Promise<void>
51
+ /** Bind an existing on-disk document (opened from PureFiles/recents). */
52
+ adopt: (path: string, opts?: { title?: string }) => void
53
+ /** Detach — back to `none` (⌘N / new document). */
54
+ reset: () => void
55
+ }
56
+
57
+ function titleFromPath(path: string, suffix: string): string {
58
+ const name = path.split('/').pop() ?? path
59
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name
60
+ }
61
+
62
+ function statusForPath(path: string): DocumentLifecycleStatus {
63
+ return path.includes('/PureDrafts/') ? 'draft' : 'filed'
64
+ }
65
+
66
+ export function useDocumentLifecycle(
67
+ options: UseDocumentLifecycleOptions,
68
+ ): DocumentLifecycle {
69
+ const { appSlug, suffix, kind, suggestedTitle } = options
70
+ const debounceMs = options.debounceMs ?? 1500
71
+ const serializeRef = useRef(options.serialize)
72
+ serializeRef.current = options.serialize
73
+
74
+ const [doc, setDoc] = useState<DocumentLifecycleDoc>({
75
+ path: null,
76
+ title: suggestedTitle ?? '',
77
+ status: 'none',
78
+ savedAt: null,
79
+ saving: false,
80
+ error: null,
81
+ })
82
+ const pathRef = useRef<string | null>(null)
83
+ const timerRef = useRef<number | null>(null)
84
+ // Serialize all writes for one document through a promise chain — never two
85
+ // concurrent autosaves, and flush() awaits everything queued before it.
86
+ const writeChainRef = useRef<Promise<void>>(Promise.resolve())
87
+ const creatingRef = useRef<Promise<string | null> | null>(null)
88
+
89
+ useEffect(
90
+ () => () => {
91
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current)
92
+ },
93
+ [],
94
+ )
95
+
96
+ const queueWrite = useCallback(
97
+ (work: () => Promise<void>): Promise<void> => {
98
+ const next = writeChainRef.current.then(work, work)
99
+ writeChainRef.current = next.catch(() => undefined)
100
+ return next
101
+ },
102
+ [],
103
+ )
104
+
105
+ const saveNow = useCallback(async (): Promise<void> => {
106
+ const path = pathRef.current
107
+ if (!path) return
108
+ setDoc(current => ({ ...current, saving: true }))
109
+ try {
110
+ const { savedAt } = await autosavePlatformDocument({
111
+ path,
112
+ files: serializeRef.current(),
113
+ })
114
+ setDoc(current => ({ ...current, saving: false, savedAt, error: null }))
115
+ } catch (error) {
116
+ setDoc(current => ({
117
+ ...current,
118
+ saving: false,
119
+ error: error instanceof Error ? error.message : 'Could not save.',
120
+ }))
121
+ }
122
+ }, [])
123
+
124
+ const ensureDraft = useCallback(async (): Promise<string | null> => {
125
+ if (pathRef.current) return pathRef.current
126
+ if (creatingRef.current) return creatingRef.current
127
+ creatingRef.current = (async () => {
128
+ try {
129
+ const { path } = await createPlatformDraft({
130
+ appSlug,
131
+ suffix,
132
+ kind,
133
+ files: serializeRef.current(),
134
+ })
135
+ pathRef.current = path
136
+ setDoc(current => ({
137
+ ...current,
138
+ path,
139
+ title: current.title || titleFromPath(path, suffix),
140
+ status: 'draft',
141
+ savedAt: new Date().toISOString(),
142
+ error: null,
143
+ }))
144
+ void touchPlatformRecentDocument({
145
+ appSlug,
146
+ path,
147
+ title: titleFromPath(path, suffix),
148
+ }).catch(() => undefined)
149
+ return path
150
+ } catch (error) {
151
+ setDoc(current => ({
152
+ ...current,
153
+ error:
154
+ error instanceof Error ? error.message : 'Could not create draft.',
155
+ }))
156
+ return null
157
+ } finally {
158
+ creatingRef.current = null
159
+ }
160
+ })()
161
+ return creatingRef.current
162
+ }, [appSlug, kind, suffix])
163
+
164
+ const markDirty = useCallback((): void => {
165
+ void (async () => {
166
+ if (!pathRef.current) {
167
+ const created = await ensureDraft()
168
+ if (!created) return
169
+ }
170
+ if (timerRef.current !== null) window.clearTimeout(timerRef.current)
171
+ timerRef.current = window.setTimeout(() => {
172
+ timerRef.current = null
173
+ void queueWrite(saveNow)
174
+ }, debounceMs)
175
+ })()
176
+ }, [debounceMs, ensureDraft, queueWrite, saveNow])
177
+
178
+ const flush = useCallback(async (): Promise<void> => {
179
+ if (timerRef.current !== null) {
180
+ window.clearTimeout(timerRef.current)
181
+ timerRef.current = null
182
+ }
183
+ if (!pathRef.current) return
184
+ await queueWrite(saveNow)
185
+ }, [queueWrite, saveNow])
186
+
187
+ // Last-chance flush when the app tab hides or unloads.
188
+ useEffect(() => {
189
+ const onHidden = (): void => {
190
+ if (document.visibilityState === 'hidden') void flush()
191
+ }
192
+ window.addEventListener('beforeunload', onHidden)
193
+ document.addEventListener('visibilitychange', onHidden)
194
+ return () => {
195
+ window.removeEventListener('beforeunload', onHidden)
196
+ document.removeEventListener('visibilitychange', onHidden)
197
+ }
198
+ }, [flush])
199
+
200
+ const promote = useCallback(
201
+ async (title: string, targetDir?: string): Promise<void> => {
202
+ const path = pathRef.current ?? (await ensureDraft())
203
+ if (!path) return
204
+ await flush()
205
+ try {
206
+ const result = await promotePlatformDocument({
207
+ path,
208
+ title,
209
+ targetDir,
210
+ suffix,
211
+ appSlug,
212
+ })
213
+ pathRef.current = result.path
214
+ setDoc(current => ({
215
+ ...current,
216
+ path: result.path,
217
+ title: titleFromPath(result.path, suffix),
218
+ status: statusForPath(result.path),
219
+ error: null,
220
+ }))
221
+ void touchPlatformRecentDocument({
222
+ appSlug,
223
+ path: result.path,
224
+ title: titleFromPath(result.path, suffix),
225
+ }).catch(() => undefined)
226
+ } catch (error) {
227
+ setDoc(current => ({
228
+ ...current,
229
+ error: error instanceof Error ? error.message : 'Could not file.',
230
+ }))
231
+ }
232
+ },
233
+ [appSlug, ensureDraft, flush, suffix],
234
+ )
235
+
236
+ const rename = useCallback(
237
+ async (title: string): Promise<void> => {
238
+ const path = pathRef.current
239
+ if (!path) {
240
+ setDoc(current => ({ ...current, title }))
241
+ return
242
+ }
243
+ await flush()
244
+ try {
245
+ const result = await renamePlatformDocument({
246
+ path,
247
+ title,
248
+ suffix,
249
+ appSlug,
250
+ })
251
+ pathRef.current = result.path
252
+ setDoc(current => ({
253
+ ...current,
254
+ path: result.path,
255
+ title: titleFromPath(result.path, suffix),
256
+ status: statusForPath(result.path),
257
+ error: null,
258
+ }))
259
+ } catch (error) {
260
+ setDoc(current => ({
261
+ ...current,
262
+ error: error instanceof Error ? error.message : 'Could not rename.',
263
+ }))
264
+ }
265
+ },
266
+ [appSlug, flush, suffix],
267
+ )
268
+
269
+ const duplicate = useCallback(async (): Promise<string | null> => {
270
+ const path = pathRef.current
271
+ if (!path) return null
272
+ await flush()
273
+ try {
274
+ const result = await duplicatePlatformDocument({ path, suffix })
275
+ return result.path
276
+ } catch {
277
+ return null
278
+ }
279
+ }, [flush, suffix])
280
+
281
+ const revealInFinder = useCallback(async (): Promise<void> => {
282
+ const path = pathRef.current
283
+ if (!path) return
284
+ await bridge.call(PLATFORM_BRIDGE_METHODS.OS_REVEAL, [path])
285
+ }, [])
286
+
287
+ const openInFiles = useCallback(async (): Promise<void> => {
288
+ const path = pathRef.current
289
+ if (!path) return
290
+ await bridge.call(PLATFORM_BRIDGE_METHODS.CATALOG_OPEN, [
291
+ { path, appSlug: 'purefiles' },
292
+ ])
293
+ }, [])
294
+
295
+ const adopt = useCallback(
296
+ (path: string, opts: { title?: string } = {}): void => {
297
+ pathRef.current = path
298
+ setDoc({
299
+ path,
300
+ title: opts.title ?? titleFromPath(path, suffix),
301
+ status: statusForPath(path),
302
+ savedAt: null,
303
+ saving: false,
304
+ error: null,
305
+ })
306
+ void touchPlatformRecentDocument({
307
+ appSlug,
308
+ path,
309
+ title: opts.title ?? titleFromPath(path, suffix),
310
+ }).catch(() => undefined)
311
+ },
312
+ [appSlug, suffix],
313
+ )
314
+
315
+ const reset = useCallback((): void => {
316
+ if (timerRef.current !== null) {
317
+ window.clearTimeout(timerRef.current)
318
+ timerRef.current = null
319
+ }
320
+ pathRef.current = null
321
+ setDoc({
322
+ path: null,
323
+ title: '',
324
+ status: 'none',
325
+ savedAt: null,
326
+ saving: false,
327
+ error: null,
328
+ })
329
+ }, [])
330
+
331
+ return useMemo(
332
+ () => ({
333
+ doc,
334
+ ensureDraft,
335
+ markDirty,
336
+ flush,
337
+ promote,
338
+ rename,
339
+ duplicate,
340
+ revealInFinder,
341
+ openInFiles,
342
+ adopt,
343
+ reset,
344
+ }),
345
+ [
346
+ doc,
347
+ ensureDraft,
348
+ markDirty,
349
+ flush,
350
+ promote,
351
+ rename,
352
+ duplicate,
353
+ revealInFinder,
354
+ openInFiles,
355
+ adopt,
356
+ reset,
357
+ ],
358
+ )
359
+ }
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import {
5
+ afterEach,
6
+ beforeAll,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from 'vitest'
13
+ import { usePlatformAppSettings } from './usePlatformAppSettings.js'
14
+
15
+ type ReactActGlobal = typeof globalThis & {
16
+ IS_REACT_ACT_ENVIRONMENT?: boolean
17
+ }
18
+
19
+ type Settings = Record<string, unknown> & {
20
+ view: 'list' | 'board'
21
+ }
22
+
23
+ const appSettingsMock = vi.hoisted(() => ({
24
+ getPlatformAppSettings: vi.fn(),
25
+ updatePlatformAppSettings: vi.fn(),
26
+ }))
27
+
28
+ vi.mock('../appSettings.mjs', () => appSettingsMock)
29
+
30
+ let latest:
31
+ | ReturnType<typeof usePlatformAppSettings<Settings>>
32
+ | null = null
33
+
34
+ function parseSettings(raw: Record<string, unknown>): Settings {
35
+ return {
36
+ view: raw.view === 'board' ? 'board' : 'list',
37
+ }
38
+ }
39
+
40
+ function Probe(): React.ReactElement {
41
+ latest = usePlatformAppSettings<Settings>({
42
+ appSlug: 'tasks',
43
+ parse: parseSettings,
44
+ initialSettings: { view: 'list' },
45
+ })
46
+ return <div />
47
+ }
48
+
49
+ async function flushEffects(): Promise<void> {
50
+ await act(async () => {
51
+ await Promise.resolve()
52
+ await Promise.resolve()
53
+ })
54
+ }
55
+
56
+ describe('usePlatformAppSettings', () => {
57
+ let host: HTMLDivElement
58
+ let root: Root
59
+
60
+ beforeAll(() => {
61
+ ;(globalThis as ReactActGlobal).IS_REACT_ACT_ENVIRONMENT = true
62
+ })
63
+
64
+ beforeEach(() => {
65
+ latest = null
66
+ host = document.createElement('div')
67
+ document.body.appendChild(host)
68
+ root = createRoot(host)
69
+ appSettingsMock.getPlatformAppSettings.mockResolvedValue({ view: 'board' })
70
+ appSettingsMock.updatePlatformAppSettings.mockResolvedValue({ view: 'list' })
71
+ })
72
+
73
+ afterEach(() => {
74
+ act(() => root.unmount())
75
+ host.remove()
76
+ vi.clearAllMocks()
77
+ })
78
+
79
+ it('loads and normalizes app settings for one app slug', async () => {
80
+ act(() => {
81
+ root.render(<Probe />)
82
+ })
83
+ await flushEffects()
84
+
85
+ expect(appSettingsMock.getPlatformAppSettings).toHaveBeenCalledWith('tasks')
86
+ expect(latest?.settings).toEqual({ view: 'board' })
87
+ })
88
+
89
+ it('patches app settings through settings.app.update', async () => {
90
+ act(() => {
91
+ root.render(<Probe />)
92
+ })
93
+ await flushEffects()
94
+
95
+ await act(async () => {
96
+ await latest?.patchSettings({ view: 'list' })
97
+ })
98
+
99
+ expect(appSettingsMock.updatePlatformAppSettings).toHaveBeenCalledWith({
100
+ appSlug: 'tasks',
101
+ patch: { view: 'list' },
102
+ })
103
+ expect(latest?.settings).toEqual({ view: 'list' })
104
+ })
105
+ })
@@ -0,0 +1,104 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import {
3
+ getPlatformAppSettings,
4
+ updatePlatformAppSettings,
5
+ } from '../appSettings.mjs'
6
+
7
+ export interface UsePlatformAppSettingsOptions<
8
+ TSettings extends Record<string, unknown> = Record<string, unknown>,
9
+ > {
10
+ appSlug: string | null
11
+ /** Wait for bridge ready before loading (default: true when appSlug exists). */
12
+ enabled?: boolean
13
+ /** Normalize stored settings into the app-owned settings shape. */
14
+ parse?: (value: Record<string, unknown>) => TSettings
15
+ initialSettings?: TSettings
16
+ }
17
+
18
+ export interface UsePlatformAppSettingsResult<
19
+ TSettings extends Record<string, unknown> = Record<string, unknown>,
20
+ > {
21
+ settings: TSettings
22
+ loading: boolean
23
+ error: Error | null
24
+ /** Shallow-merge a patch through shell `settings.app.update`. */
25
+ patchSettings: (patch: Record<string, unknown>) => Promise<TSettings>
26
+ reload: () => void
27
+ }
28
+
29
+ function defaultParse<TSettings extends Record<string, unknown>>(
30
+ value: Record<string, unknown>,
31
+ ): TSettings {
32
+ return value as TSettings
33
+ }
34
+
35
+ /**
36
+ * Load and update the current app's settings through `settings.app.*`.
37
+ * App settings are small per-app preferences, not user-visible documents.
38
+ */
39
+ export function usePlatformAppSettings<
40
+ TSettings extends Record<string, unknown> = Record<string, unknown>,
41
+ >({
42
+ appSlug,
43
+ enabled = Boolean(appSlug),
44
+ parse = defaultParse<TSettings>,
45
+ initialSettings = {} as TSettings,
46
+ }: UsePlatformAppSettingsOptions<TSettings>): UsePlatformAppSettingsResult<TSettings> {
47
+ const [settings, setSettings] = useState<TSettings>(initialSettings)
48
+ const [loading, setLoading] = useState(false)
49
+ const [error, setError] = useState<Error | null>(null)
50
+ const [reloadToken, setReloadToken] = useState(0)
51
+
52
+ const reload = useCallback(() => {
53
+ setReloadToken(token => token + 1)
54
+ }, [])
55
+
56
+ useEffect(() => {
57
+ if (!enabled || !appSlug) return
58
+
59
+ let cancelled = false
60
+ setLoading(true)
61
+ setError(null)
62
+
63
+ void getPlatformAppSettings(appSlug)
64
+ .then(raw => {
65
+ if (!cancelled) setSettings(parse(raw))
66
+ })
67
+ .catch((cause: unknown) => {
68
+ if (!cancelled) {
69
+ setError(cause instanceof Error ? cause : new Error(String(cause)))
70
+ }
71
+ })
72
+ .finally(() => {
73
+ if (!cancelled) setLoading(false)
74
+ })
75
+
76
+ return () => {
77
+ cancelled = true
78
+ }
79
+ }, [appSlug, enabled, parse, reloadToken])
80
+
81
+ const patchSettings = useCallback(
82
+ async (patch: Record<string, unknown>): Promise<TSettings> => {
83
+ if (!appSlug) throw new Error('appSlug is required to update app settings')
84
+ try {
85
+ const next = parse(
86
+ await updatePlatformAppSettings({
87
+ appSlug,
88
+ patch,
89
+ }),
90
+ )
91
+ setSettings(next)
92
+ setError(null)
93
+ return next
94
+ } catch (cause) {
95
+ const error = cause instanceof Error ? cause : new Error(String(cause))
96
+ setError(error)
97
+ throw error
98
+ }
99
+ },
100
+ [appSlug, parse],
101
+ )
102
+
103
+ return { settings, loading, error, patchSettings, reload }
104
+ }
@@ -0,0 +1,152 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import {
5
+ afterEach,
6
+ beforeAll,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from 'vitest'
13
+ import { usePlatformJsonStore } from './usePlatformJsonStore.js'
14
+
15
+ type ReactActGlobal = typeof globalThis & {
16
+ IS_REACT_ACT_ENVIRONMENT?: boolean
17
+ }
18
+
19
+ interface Store {
20
+ items: string[]
21
+ }
22
+
23
+ const storageMock = vi.hoisted(() => ({
24
+ readPlatformStorageJson: vi.fn(),
25
+ writePlatformStorageJson: vi.fn(),
26
+ }))
27
+
28
+ vi.mock('../storage.mjs', () => storageMock)
29
+
30
+ let latest: ReturnType<typeof usePlatformJsonStore<Store>> | null = null
31
+
32
+ const EMPTY_STORE: Store = { items: [] }
33
+
34
+ function parseStore(value: unknown): Store {
35
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
36
+ return EMPTY_STORE
37
+ }
38
+ const record = value as Record<string, unknown>
39
+ return {
40
+ items: Array.isArray(record.items)
41
+ ? record.items.filter((item): item is string => typeof item === 'string')
42
+ : [],
43
+ }
44
+ }
45
+
46
+ function Probe(): React.ReactElement {
47
+ latest = usePlatformJsonStore<Store>({
48
+ appSlug: 'tasks',
49
+ fileName: 'tasks-items.json',
50
+ initialValue: EMPTY_STORE,
51
+ parse: parseStore,
52
+ })
53
+ return <div />
54
+ }
55
+
56
+ async function flushEffects(): Promise<void> {
57
+ await act(async () => {
58
+ await Promise.resolve()
59
+ await Promise.resolve()
60
+ })
61
+ }
62
+
63
+ describe('usePlatformJsonStore', () => {
64
+ let host: HTMLDivElement
65
+ let root: Root
66
+
67
+ beforeAll(() => {
68
+ ;(globalThis as ReactActGlobal).IS_REACT_ACT_ENVIRONMENT = true
69
+ })
70
+
71
+ beforeEach(() => {
72
+ latest = null
73
+ host = document.createElement('div')
74
+ document.body.appendChild(host)
75
+ root = createRoot(host)
76
+ storageMock.readPlatformStorageJson.mockResolvedValue({
77
+ path: '/data/tasks-items.json',
78
+ value: { items: ['one', 2, 'two'] },
79
+ })
80
+ storageMock.writePlatformStorageJson.mockResolvedValue({
81
+ path: '/data/tasks-items.json',
82
+ ok: true,
83
+ })
84
+ })
85
+
86
+ afterEach(() => {
87
+ act(() => root.unmount())
88
+ host.remove()
89
+ vi.clearAllMocks()
90
+ })
91
+
92
+ it('loads and normalizes a JSON store file', async () => {
93
+ act(() => {
94
+ root.render(<Probe />)
95
+ })
96
+ await flushEffects()
97
+
98
+ expect(storageMock.readPlatformStorageJson).toHaveBeenCalledWith({
99
+ appSlug: 'tasks',
100
+ fileName: 'tasks-items.json',
101
+ })
102
+ expect(latest?.value).toEqual({ items: ['one', 'two'] })
103
+ expect(latest?.path).toBe('/data/tasks-items.json')
104
+ })
105
+
106
+ it('saves the complete next JSON store value', async () => {
107
+ act(() => {
108
+ root.render(<Probe />)
109
+ })
110
+ await flushEffects()
111
+
112
+ await act(async () => {
113
+ await latest?.saveValue({ items: ['next'] })
114
+ })
115
+
116
+ expect(storageMock.writePlatformStorageJson).toHaveBeenCalledWith({
117
+ appSlug: 'tasks',
118
+ fileName: 'tasks-items.json',
119
+ value: { items: ['next'] },
120
+ })
121
+ expect(latest?.value).toEqual({ items: ['next'] })
122
+ })
123
+
124
+ it('applies queued updates to the latest saved value', async () => {
125
+ act(() => {
126
+ root.render(<Probe />)
127
+ })
128
+ await flushEffects()
129
+
130
+ await act(async () => {
131
+ const first = latest?.updateValue(current => ({
132
+ items: [...current.items, 'three'],
133
+ }))
134
+ const second = latest?.updateValue(current => ({
135
+ items: [...current.items, 'four'],
136
+ }))
137
+ await Promise.all([first, second])
138
+ })
139
+
140
+ expect(storageMock.writePlatformStorageJson).toHaveBeenNthCalledWith(1, {
141
+ appSlug: 'tasks',
142
+ fileName: 'tasks-items.json',
143
+ value: { items: ['one', 'two', 'three'] },
144
+ })
145
+ expect(storageMock.writePlatformStorageJson).toHaveBeenNthCalledWith(2, {
146
+ appSlug: 'tasks',
147
+ fileName: 'tasks-items.json',
148
+ value: { items: ['one', 'two', 'three', 'four'] },
149
+ })
150
+ expect(latest?.value).toEqual({ items: ['one', 'two', 'three', 'four'] })
151
+ })
152
+ })