@puredesktop/puredesktop-ui-bridge 2.1.7 → 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.
- package/package.json +55 -1
- package/src/bridge/agentTypes.ts +21 -1
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/methods.d.mts +23 -0
- package/src/bridge/methods.mjs +23 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/storage.d.mts +6 -6
- package/src/bridge/storage.test.ts +6 -6
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentDrawerPanel.tsx +2 -0
- package/src/components/agents/AgentMessageList.tsx +6 -1
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/agentPanelStyles.ts +3 -2
- package/src/components/agents/agentTypes.ts +2 -0
- package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +10 -1
- package/src/components/common/containers/AppHeader.tsx +22 -0
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +87 -104
- package/src/theme/appIdentityCss.mjs +169 -231
- 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
|
+
})
|