@arronqzy/react-view 1.0.28 → 1.0.29
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 +7 -7
- package/src/panel/ReactViewPanel.tsx +21 -6
- package/src/panel/components/PanelConfigSidebar.tsx +32 -0
- package/src/panel/constants/messages.ts +2 -0
- package/src/panel/hooks/usePanelElements.ts +8 -1
- package/src/panel/hooks/useWorkspaceProjects.ts +84 -49
- package/src/panel/library/workspace-project-db.ts +163 -16
- package/src/panel/library/workspace-storage-error.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arronqzy/react-view",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.29",
|
|
4
4
|
"files": [
|
|
5
5
|
"src",
|
|
6
6
|
"dist",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"@codemirror/theme-one-dark": "^6.1.2",
|
|
32
32
|
"@codemirror/view": "^6.36.2",
|
|
33
33
|
"@arronqzy/ui": "1.0.9",
|
|
34
|
-
"@arronqzy/react-blueprint": "1.0.16",
|
|
35
34
|
"@arronqzy/blueprint-dsl": "1.0.14",
|
|
36
|
-
"@arronqzy/i18n": "0.1.
|
|
35
|
+
"@arronqzy/i18n": "0.1.12",
|
|
36
|
+
"@arronqzy/react-blueprint": "1.0.16",
|
|
37
37
|
"@arronqzy/rx-store": "1.0.8",
|
|
38
|
+
"@arronqzy/view-table": "0.1.8",
|
|
38
39
|
"@arronqzy/view-scene3d": "0.1.5",
|
|
39
|
-
"@arronqzy/webllm-assistant": "0.1.16"
|
|
40
|
-
"@arronqzy/view-table": "0.1.8"
|
|
40
|
+
"@arronqzy/webllm-assistant": "0.1.16"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/react": "^19.0.1",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"eslint": "^8.57.0",
|
|
47
47
|
"typescript": "^5.5.4",
|
|
48
48
|
"@arronqzy/eslint-config": "0.0.0",
|
|
49
|
-
"@arronqzy/
|
|
50
|
-
"@arronqzy/
|
|
49
|
+
"@arronqzy/typescript-config": "1.0.0",
|
|
50
|
+
"@arronqzy/tailwind": "1.0.2"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
@@ -1079,11 +1079,18 @@ function ReactViewPanelInner({
|
|
|
1079
1079
|
);
|
|
1080
1080
|
|
|
1081
1081
|
const handleWorkspaceCreateProject = useCallback(async () => {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1082
|
+
try {
|
|
1083
|
+
const result = await workspaceProjects.handleCreateProject();
|
|
1084
|
+
if (result?.name) {
|
|
1085
|
+
toast({ title: messages.workspaceCreated(result.name) });
|
|
1086
|
+
}
|
|
1087
|
+
return result;
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
toast({
|
|
1090
|
+
title: error instanceof Error ? error.message : t("panel.messages.createWorkspaceFailed"),
|
|
1091
|
+
});
|
|
1092
|
+
throw error;
|
|
1085
1093
|
}
|
|
1086
|
-
return result;
|
|
1087
1094
|
}, [workspaceProjects]);
|
|
1088
1095
|
|
|
1089
1096
|
const handleWorkspaceOpenProject = useCallback(
|
|
@@ -1121,8 +1128,16 @@ function ReactViewPanelInner({
|
|
|
1121
1128
|
|
|
1122
1129
|
const handleWorkspaceDeleteProject = useCallback(
|
|
1123
1130
|
async (id: string) => {
|
|
1124
|
-
|
|
1125
|
-
|
|
1131
|
+
try {
|
|
1132
|
+
await workspaceProjects.handleDeleteProject(id);
|
|
1133
|
+
toast({ title: t("panel.messages.workspaceDeleted") });
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
toast({
|
|
1136
|
+
title:
|
|
1137
|
+
error instanceof Error ? error.message : t("panel.messages.deleteWorkspaceFailed"),
|
|
1138
|
+
});
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1126
1141
|
},
|
|
1127
1142
|
[workspaceProjects]
|
|
1128
1143
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
3
4
|
useMemo,
|
|
4
5
|
useRef,
|
|
5
6
|
useState,
|
|
@@ -146,6 +147,37 @@ export function PanelConfigSidebar({
|
|
|
146
147
|
return selectedElement ? [selectedElement] : [];
|
|
147
148
|
}, [selectedElement, selectedElements]);
|
|
148
149
|
const isMultiSelectMode = effectiveSelectedElements.length > 1;
|
|
150
|
+
const configScrollKey =
|
|
151
|
+
!isMultiSelectMode && selectedElement ? selectedElement.id : null;
|
|
152
|
+
const configScrollPosByIdRef = useRef(new Map<string, number>());
|
|
153
|
+
const activeConfigScrollKeyRef = useRef<string | null>(null);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const el = sidebarScrollRef.current;
|
|
157
|
+
if (!el) return;
|
|
158
|
+
const onScroll = () => {
|
|
159
|
+
const key = activeConfigScrollKeyRef.current;
|
|
160
|
+
if (!key) return;
|
|
161
|
+
configScrollPosByIdRef.current.set(key, el.scrollTop);
|
|
162
|
+
};
|
|
163
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
164
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
useLayoutEffect(() => {
|
|
168
|
+
const el = sidebarScrollRef.current;
|
|
169
|
+
const prevKey = activeConfigScrollKeyRef.current;
|
|
170
|
+
if (el && prevKey && prevKey !== configScrollKey) {
|
|
171
|
+
configScrollPosByIdRef.current.set(prevKey, el.scrollTop);
|
|
172
|
+
}
|
|
173
|
+
activeConfigScrollKeyRef.current = configScrollKey;
|
|
174
|
+
if (!el) return;
|
|
175
|
+
const top =
|
|
176
|
+
configScrollKey != null
|
|
177
|
+
? (configScrollPosByIdRef.current.get(configScrollKey) ?? 0)
|
|
178
|
+
: 0;
|
|
179
|
+
el.scrollTop = top;
|
|
180
|
+
}, [configScrollKey]);
|
|
149
181
|
|
|
150
182
|
useEffect(() => {
|
|
151
183
|
if (!selectedElement) return;
|
|
@@ -52,7 +52,9 @@ export function getPanelMessages(t: TranslateFn) {
|
|
|
52
52
|
workspaceLoaded: t("panel.messages.workspaceLoaded"),
|
|
53
53
|
workspaceLoadedDesc: t("panel.messages.workspaceLoadedDesc"),
|
|
54
54
|
openWorkspaceFailed: t("panel.messages.openWorkspaceFailed"),
|
|
55
|
+
createWorkspaceFailed: t("panel.messages.createWorkspaceFailed"),
|
|
55
56
|
syncFailed: t("panel.messages.syncFailed"),
|
|
57
|
+
deleteWorkspaceFailed: t("panel.messages.deleteWorkspaceFailed"),
|
|
56
58
|
workspaceDeleted: t("panel.messages.workspaceDeleted"),
|
|
57
59
|
openPreviewFailed: t("panel.messages.openPreviewFailed"),
|
|
58
60
|
clearedBlueprintLogs: (removed: number) =>
|
|
@@ -1077,7 +1077,14 @@ export function usePanelElements() {
|
|
|
1077
1077
|
/* fall through */
|
|
1078
1078
|
}
|
|
1079
1079
|
}
|
|
1080
|
-
|
|
1080
|
+
try {
|
|
1081
|
+
return JSON.parse(JSON.stringify(current)) as State;
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
throw Object.assign(new Error("indexeddb-serialize-failed"), {
|
|
1084
|
+
name: "DataCloneError",
|
|
1085
|
+
cause: error,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1081
1088
|
}, []);
|
|
1082
1089
|
|
|
1083
1090
|
const importPanelData = useCallback((nextState: State) => {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
workspaceSnapshotsEqual,
|
|
20
20
|
type WorkspaceSnapshot,
|
|
21
21
|
} from "../library/workspace-snapshot";
|
|
22
|
+
import { mapWorkspaceStorageError } from "../library/workspace-storage-error";
|
|
22
23
|
import { runBusyTask } from "../utils/async-work";
|
|
23
24
|
import { AbuilderEvents, emitAbuilderEvent } from "../library/event-subscription";
|
|
24
25
|
import { parseBlueprintData } from "@arronqzy/react-blueprint";
|
|
@@ -145,8 +146,13 @@ export function useWorkspaceProjects({
|
|
|
145
146
|
}, [blueprintDocument, blueprintMeta, productName, titleIconDataUrl]);
|
|
146
147
|
|
|
147
148
|
const refreshProjects = useCallback(async () => {
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
try {
|
|
150
|
+
const items = await listWorkspaceProjects(nameSpace);
|
|
151
|
+
setProjects(items);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Listing failure should not brick the editor; create/sync still surface errors.
|
|
154
|
+
console.warn("[abuilder] listWorkspaceProjects failed", error);
|
|
155
|
+
}
|
|
150
156
|
}, [nameSpace]);
|
|
151
157
|
|
|
152
158
|
useEffect(() => {
|
|
@@ -272,37 +278,58 @@ export function useWorkspaceProjects({
|
|
|
272
278
|
|
|
273
279
|
const persistProject = useCallback(
|
|
274
280
|
async (options: { id?: string; name: string; createdAt?: number }) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
try {
|
|
282
|
+
const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
const id = options.id ?? createWorkspaceProjectId();
|
|
285
|
+
const createdAt = options.createdAt ?? now;
|
|
286
|
+
const name = (options.name ?? "").trim() || resolveProjectName();
|
|
280
287
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
blueprintDocument: snapshot.blueprintDocument,
|
|
288
|
-
blueprintMeta: snapshot.blueprintMeta,
|
|
289
|
-
productName:
|
|
290
|
-
typeof snapshot.productName === "string" ? snapshot.productName : resolveProjectName(),
|
|
291
|
-
titleIconDataUrl: snapshot.titleIconDataUrl || undefined,
|
|
292
|
-
};
|
|
288
|
+
let panelState: State;
|
|
289
|
+
try {
|
|
290
|
+
panelState = exportPanelData();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw mapWorkspaceStorageError(error, t);
|
|
293
|
+
}
|
|
293
294
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
295
|
+
const record: WorkspaceProjectRecord = {
|
|
296
|
+
id,
|
|
297
|
+
name,
|
|
298
|
+
createdAt,
|
|
299
|
+
updatedAt: now,
|
|
300
|
+
panelState,
|
|
301
|
+
blueprintDocument: snapshot.blueprintDocument,
|
|
302
|
+
blueprintMeta: snapshot.blueprintMeta,
|
|
303
|
+
productName:
|
|
304
|
+
typeof snapshot.productName === "string"
|
|
305
|
+
? snapshot.productName
|
|
306
|
+
: resolveProjectName(),
|
|
307
|
+
titleIconDataUrl: snapshot.titleIconDataUrl || undefined,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
await putWorkspaceProject(record, nameSpace);
|
|
311
|
+
writeWorkspacePreviewCache(record, nameSpace);
|
|
312
|
+
broadcastWorkspaceProjectUpdate(id, now, nameSpace);
|
|
313
|
+
setActiveProjectId(id);
|
|
314
|
+
setActiveProjectName(name);
|
|
315
|
+
syncedSnapshotRef.current = snapshot;
|
|
316
|
+
syncedPanelRevisionRef.current = panelRevision;
|
|
317
|
+
pendingRevisionSyncRef.current = false;
|
|
318
|
+
await refreshProjects();
|
|
319
|
+
return record;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw mapWorkspaceStorageError(error, t);
|
|
322
|
+
}
|
|
304
323
|
},
|
|
305
|
-
[
|
|
324
|
+
[
|
|
325
|
+
buildCurrentSnapshot,
|
|
326
|
+
exportPanelData,
|
|
327
|
+
nameSpace,
|
|
328
|
+
panelRevision,
|
|
329
|
+
resolveProjectName,
|
|
330
|
+
refreshProjects,
|
|
331
|
+
t,
|
|
332
|
+
]
|
|
306
333
|
);
|
|
307
334
|
|
|
308
335
|
const handleCreateProject = useCallback(async () => {
|
|
@@ -332,14 +359,18 @@ export function useWorkspaceProjects({
|
|
|
332
359
|
|
|
333
360
|
const handleOpenProject = useCallback(
|
|
334
361
|
async (id: string) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
362
|
+
try {
|
|
363
|
+
await runBusyTask(t("common.loadingWorkspace"), async () => {
|
|
364
|
+
const record = await getWorkspaceProject(id, nameSpace);
|
|
365
|
+
if (!record) {
|
|
366
|
+
await refreshProjects();
|
|
367
|
+
throw new Error(t("panel.messages.workspaceNotFound"));
|
|
368
|
+
}
|
|
369
|
+
applyProjectRecord(record);
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw mapWorkspaceStorageError(error, t);
|
|
373
|
+
}
|
|
343
374
|
},
|
|
344
375
|
[applyProjectRecord, nameSpace, refreshProjects, t]
|
|
345
376
|
);
|
|
@@ -370,17 +401,21 @@ export function useWorkspaceProjects({
|
|
|
370
401
|
|
|
371
402
|
const handleDeleteProject = useCallback(
|
|
372
403
|
async (id: string) => {
|
|
373
|
-
|
|
374
|
-
await
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
404
|
+
try {
|
|
405
|
+
await runBusyTask(t("common.deletingWorkspace"), async () => {
|
|
406
|
+
await deleteWorkspaceProject(id, nameSpace);
|
|
407
|
+
if (activeProjectId === id) {
|
|
408
|
+
setActiveProjectId(null);
|
|
409
|
+
setActiveProjectName(null);
|
|
410
|
+
syncedSnapshotRef.current = null;
|
|
411
|
+
}
|
|
412
|
+
previewWindowsRef.current.delete(id);
|
|
413
|
+
refreshPreviewing();
|
|
414
|
+
await refreshProjects();
|
|
415
|
+
});
|
|
416
|
+
} catch (error) {
|
|
417
|
+
throw mapWorkspaceStorageError(error, t);
|
|
418
|
+
}
|
|
384
419
|
},
|
|
385
420
|
[activeProjectId, nameSpace, refreshPreviewing, refreshProjects, t]
|
|
386
421
|
);
|
|
@@ -6,6 +6,7 @@ import { appStorageKey } from "@arronqzy/blueprint-dsl";
|
|
|
6
6
|
const DB_NAME = "arronqzy-workspace-projects";
|
|
7
7
|
const DB_VERSION = 1;
|
|
8
8
|
const STORE_NAME = "projects";
|
|
9
|
+
const OPEN_TIMEOUT_MS = 10_000;
|
|
9
10
|
|
|
10
11
|
export type WorkspaceProjectListItem = {
|
|
11
12
|
id: string;
|
|
@@ -26,10 +27,77 @@ export type WorkspaceProjectRecord = {
|
|
|
26
27
|
titleIconDataUrl?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
const dbPromises = new Map<string, Promise<IDBDatabase>>();
|
|
31
|
+
|
|
32
|
+
function getIndexedDb(): IDBFactory {
|
|
33
|
+
if (typeof indexedDB === "undefined" || !indexedDB) {
|
|
34
|
+
throw Object.assign(new Error("indexeddb-unavailable"), {
|
|
35
|
+
name: "IndexedDbUnavailable",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return indexedDB;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Make a value safe for IndexedDB structured clone (drops non-cloneable fields). */
|
|
42
|
+
export function cloneForIndexedDb<T>(value: T): T {
|
|
43
|
+
if (typeof structuredClone === "function") {
|
|
44
|
+
try {
|
|
45
|
+
return structuredClone(value);
|
|
46
|
+
} catch {
|
|
47
|
+
/* fall through to JSON */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw Object.assign(new Error("indexeddb-serialize-failed"), {
|
|
54
|
+
name: "DataCloneError",
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
function openDb(nameSpace?: string | null): Promise<IDBDatabase> {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
const key = appStorageKey(DB_NAME, nameSpace);
|
|
62
|
+
const cached = dbPromises.get(key);
|
|
63
|
+
if (cached) return cached;
|
|
64
|
+
|
|
65
|
+
const promise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
const fail = (error: unknown) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
dbPromises.delete(key);
|
|
71
|
+
reject(
|
|
72
|
+
error instanceof Error
|
|
73
|
+
? error
|
|
74
|
+
: Object.assign(new Error("indexeddb-open-failed"), { cause: error })
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let request: IDBOpenDBRequest;
|
|
79
|
+
try {
|
|
80
|
+
request = getIndexedDb().open(key, DB_VERSION);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
fail(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
fail(
|
|
88
|
+
Object.assign(new Error("indexeddb-open-timeout"), {
|
|
89
|
+
name: "TimeoutError",
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
}, OPEN_TIMEOUT_MS);
|
|
93
|
+
|
|
94
|
+
request.onerror = () => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
fail(request.error ?? new Error("indexeddb-open-failed"));
|
|
97
|
+
};
|
|
98
|
+
request.onblocked = () => {
|
|
99
|
+
// Wait for onsuccess / timeout; another tab may be holding an older connection.
|
|
100
|
+
};
|
|
33
101
|
request.onupgradeneeded = () => {
|
|
34
102
|
const db = request.result;
|
|
35
103
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
@@ -37,8 +105,35 @@ function openDb(nameSpace?: string | null): Promise<IDBDatabase> {
|
|
|
37
105
|
store.createIndex("updatedAt", "updatedAt", { unique: false });
|
|
38
106
|
}
|
|
39
107
|
};
|
|
40
|
-
request.onsuccess = () =>
|
|
108
|
+
request.onsuccess = () => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
if (settled) {
|
|
111
|
+
try {
|
|
112
|
+
request.result.close();
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
settled = true;
|
|
119
|
+
const db = request.result;
|
|
120
|
+
db.onversionchange = () => {
|
|
121
|
+
try {
|
|
122
|
+
db.close();
|
|
123
|
+
} catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
dbPromises.delete(key);
|
|
127
|
+
};
|
|
128
|
+
db.onclose = () => {
|
|
129
|
+
dbPromises.delete(key);
|
|
130
|
+
};
|
|
131
|
+
resolve(db);
|
|
132
|
+
};
|
|
41
133
|
});
|
|
134
|
+
|
|
135
|
+
dbPromises.set(key, promise);
|
|
136
|
+
return promise;
|
|
42
137
|
}
|
|
43
138
|
|
|
44
139
|
function runTransaction<T>(
|
|
@@ -49,27 +144,76 @@ function runTransaction<T>(
|
|
|
49
144
|
return openDb(nameSpace).then(
|
|
50
145
|
(db) =>
|
|
51
146
|
new Promise<T>((resolve, reject) => {
|
|
52
|
-
|
|
147
|
+
let tx: IDBTransaction;
|
|
148
|
+
try {
|
|
149
|
+
tx = db.transaction(STORE_NAME, mode);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Stale connection (e.g. Safari closed it); reopen once.
|
|
152
|
+
dbPromises.delete(appStorageKey(DB_NAME, nameSpace));
|
|
153
|
+
reject(error);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
53
156
|
const store = tx.objectStore(STORE_NAME);
|
|
54
157
|
const request = runner(store);
|
|
55
158
|
let result!: T;
|
|
159
|
+
let settled = false;
|
|
160
|
+
|
|
161
|
+
const fail = (error: unknown) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
reject(
|
|
165
|
+
error instanceof Error
|
|
166
|
+
? error
|
|
167
|
+
: Object.assign(new Error("indexeddb-transaction-failed"), {
|
|
168
|
+
cause: error,
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
};
|
|
56
172
|
|
|
57
173
|
request.onerror = () =>
|
|
58
|
-
|
|
174
|
+
fail(request.error ?? new Error("indexeddb-request-failed"));
|
|
59
175
|
request.onsuccess = () => {
|
|
60
176
|
result = request.result as T;
|
|
61
177
|
};
|
|
62
178
|
|
|
63
179
|
tx.oncomplete = () => {
|
|
64
|
-
|
|
180
|
+
if (settled) return;
|
|
181
|
+
settled = true;
|
|
65
182
|
resolve(result);
|
|
66
183
|
};
|
|
67
|
-
tx.onerror = () =>
|
|
68
|
-
|
|
69
|
-
reject(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
70
|
-
};
|
|
184
|
+
tx.onerror = () => fail(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
185
|
+
tx.onabort = () => fail(tx.error ?? new Error("indexeddb-transaction-aborted"));
|
|
71
186
|
})
|
|
72
|
-
)
|
|
187
|
+
).catch(async (error) => {
|
|
188
|
+
// One retry after connection reset (common on iOS/Safari when DB was closed).
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
const name = error instanceof DOMException ? error.name : "";
|
|
191
|
+
if (
|
|
192
|
+
name === "InvalidStateError" ||
|
|
193
|
+
message.includes("InvalidStateError") ||
|
|
194
|
+
message.includes("closing")
|
|
195
|
+
) {
|
|
196
|
+
dbPromises.delete(appStorageKey(DB_NAME, nameSpace));
|
|
197
|
+
const db = await openDb(nameSpace);
|
|
198
|
+
return new Promise<T>((resolve, reject) => {
|
|
199
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
200
|
+
const store = tx.objectStore(STORE_NAME);
|
|
201
|
+
const request = runner(store);
|
|
202
|
+
let result!: T;
|
|
203
|
+
request.onerror = () =>
|
|
204
|
+
reject(request.error ?? new Error("indexeddb-request-failed"));
|
|
205
|
+
request.onsuccess = () => {
|
|
206
|
+
result = request.result as T;
|
|
207
|
+
};
|
|
208
|
+
tx.oncomplete = () => resolve(result);
|
|
209
|
+
tx.onerror = () =>
|
|
210
|
+
reject(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
211
|
+
tx.onabort = () =>
|
|
212
|
+
reject(tx.error ?? new Error("indexeddb-transaction-aborted"));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
73
217
|
}
|
|
74
218
|
|
|
75
219
|
export function createWorkspaceProjectId(): string {
|
|
@@ -103,11 +247,9 @@ export async function listWorkspaceProjects(
|
|
|
103
247
|
cursor.continue();
|
|
104
248
|
};
|
|
105
249
|
tx.oncomplete = () => {
|
|
106
|
-
db.close();
|
|
107
250
|
resolve(items.sort((a, b) => b.updatedAt - a.updatedAt));
|
|
108
251
|
};
|
|
109
252
|
tx.onerror = () => {
|
|
110
|
-
db.close();
|
|
111
253
|
reject(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
112
254
|
};
|
|
113
255
|
});
|
|
@@ -129,8 +271,13 @@ export async function putWorkspaceProject(
|
|
|
129
271
|
record: WorkspaceProjectRecord,
|
|
130
272
|
nameSpace?: string | null
|
|
131
273
|
): Promise<WorkspaceProjectRecord> {
|
|
132
|
-
|
|
133
|
-
|
|
274
|
+
const safeRecord = cloneForIndexedDb(record);
|
|
275
|
+
await runTransaction<IDBValidKey>(
|
|
276
|
+
"readwrite",
|
|
277
|
+
(store) => store.put(safeRecord),
|
|
278
|
+
nameSpace
|
|
279
|
+
);
|
|
280
|
+
return safeRecord;
|
|
134
281
|
}
|
|
135
282
|
|
|
136
283
|
export async function deleteWorkspaceProject(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map IndexedDB / clone failures to localized workspace messages.
|
|
3
|
+
* Used by create / sync / open / delete so device-specific DOMExceptions
|
|
4
|
+
* show actionable copy instead of opaque browser English.
|
|
5
|
+
*/
|
|
6
|
+
export function mapWorkspaceStorageError(
|
|
7
|
+
error: unknown,
|
|
8
|
+
t: (key: string) => string
|
|
9
|
+
): Error {
|
|
10
|
+
const raw = error instanceof Error ? error : new Error(String(error));
|
|
11
|
+
const name =
|
|
12
|
+
(error as { name?: string } | null)?.name ||
|
|
13
|
+
(raw as { name?: string }).name ||
|
|
14
|
+
"";
|
|
15
|
+
const message = raw.message || "";
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
name === "IndexedDbUnavailable" ||
|
|
19
|
+
name === "SecurityError" ||
|
|
20
|
+
message.includes("indexeddb-unavailable") ||
|
|
21
|
+
/indexedDB is not available|Access is denied|The operation is insecure/i.test(
|
|
22
|
+
message
|
|
23
|
+
)
|
|
24
|
+
) {
|
|
25
|
+
return new Error(t("panel.messages.workspaceStorageUnavailable"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
name === "TimeoutError" ||
|
|
30
|
+
message.includes("indexeddb-open-timeout")
|
|
31
|
+
) {
|
|
32
|
+
return new Error(t("panel.messages.workspaceStorageTimeout"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
name === "QuotaExceededError" ||
|
|
37
|
+
/QuotaExceeded|quota|storage.*full|exceeded the quota/i.test(message)
|
|
38
|
+
) {
|
|
39
|
+
return new Error(t("panel.messages.workspaceStorageQuotaExceeded"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
name === "DataCloneError" ||
|
|
44
|
+
message.includes("indexeddb-serialize-failed") ||
|
|
45
|
+
/could not be cloned|DataCloneError|circular/i.test(message)
|
|
46
|
+
) {
|
|
47
|
+
return new Error(t("panel.messages.workspaceStorageSerializeFailed"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
message.includes("indexeddb-open-failed") ||
|
|
52
|
+
message.includes("indexeddb-transaction-failed") ||
|
|
53
|
+
message.includes("indexeddb-request-failed") ||
|
|
54
|
+
message.includes("indexeddb-transaction-aborted")
|
|
55
|
+
) {
|
|
56
|
+
return new Error(t("panel.messages.workspaceStorageFailed"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return raw;
|
|
60
|
+
}
|