@comergehq/studio 0.1.2 → 0.1.4
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/dist/index.d.mts +2 -10
- package/dist/index.d.ts +2 -10
- package/dist/index.js +293 -264
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +251 -222
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -5
- package/src/components/chat/ChatComposer.tsx +277 -0
- package/src/components/chat/ChatHeader.tsx +31 -0
- package/src/components/chat/ChatMessageBubble.tsx +69 -0
- package/src/components/chat/ChatMessageList.tsx +137 -0
- package/src/components/chat/ChatPage.tsx +69 -0
- package/src/components/chat/ForkNoticeBanner.tsx +66 -0
- package/src/components/chat/MultilineTextInput.tsx +46 -0
- package/src/components/chat/ScrollToBottomButton.tsx +78 -0
- package/src/components/chat/TypingIndicator.tsx +54 -0
- package/src/components/chat/index.ts +28 -0
- package/src/components/comments/AppCommentsSheet.tsx +213 -0
- package/src/components/comments/CommentRow.tsx +63 -0
- package/src/components/comments/formatTimeAgo.ts +3 -0
- package/src/components/comments/index.ts +3 -0
- package/src/components/comments/useAppComments.ts +74 -0
- package/src/components/comments/useAppDetails.ts +35 -0
- package/src/components/comments/useIosKeyboardSnapFix.ts +24 -0
- package/src/components/dialogs/ConfirmMergeRequestDialog.tsx +156 -0
- package/src/components/dialogs/index.ts +4 -0
- package/src/components/draw/DrawColorPicker.tsx +77 -0
- package/src/components/draw/DrawModeOverlay.tsx +144 -0
- package/src/components/draw/DrawSurface.tsx +127 -0
- package/src/components/draw/DrawToolbar.tsx +253 -0
- package/src/components/draw/index.ts +15 -0
- package/src/components/draw/optionalHaptics.ts +15 -0
- package/src/components/draw/strokes.ts +21 -0
- package/src/components/draw/types.ts +9 -0
- package/src/components/floating-draggable-button/FloatingDraggableButton.tsx +323 -0
- package/src/components/floating-draggable-button/constants.ts +17 -0
- package/src/components/floating-draggable-button/index.ts +4 -0
- package/src/components/floating-draggable-button/types.ts +63 -0
- package/src/components/icons/MergeIcon.tsx +14 -0
- package/src/components/icons/StudioIcons.tsx +66 -0
- package/src/components/index.ts +17 -0
- package/src/components/merge-requests/MergeRequestStatusCard.tsx +179 -0
- package/src/components/merge-requests/ReviewMergeRequestActionButton.tsx +62 -0
- package/src/components/merge-requests/ReviewMergeRequestCard.tsx +192 -0
- package/src/components/merge-requests/ReviewMergeRequestCarousel.tsx +132 -0
- package/src/components/merge-requests/index.ts +7 -0
- package/src/components/merge-requests/mergeRequestStatusDisplay.ts +23 -0
- package/src/components/merge-requests/toIsoString.ts +9 -0
- package/src/components/merge-requests/useControlledExpansion.ts +16 -0
- package/src/components/models/index.ts +9 -0
- package/src/components/models/types.ts +43 -0
- package/src/components/overlays/EdgeGlowFrame.tsx +105 -0
- package/src/components/overlays/index.ts +4 -0
- package/src/components/preview/PreviewHeroCard.tsx +58 -0
- package/src/components/preview/PreviewImage.tsx +22 -0
- package/src/components/preview/PreviewMetaRow.tsx +70 -0
- package/src/components/preview/PreviewPage.tsx +36 -0
- package/src/components/preview/PreviewPlaceholder.tsx +72 -0
- package/src/components/preview/PreviewStatusBadge.tsx +63 -0
- package/src/components/preview/StatsBar.tsx +109 -0
- package/src/components/preview/index.ts +22 -0
- package/src/components/primitives/Avatar.tsx +68 -0
- package/src/components/primitives/Button.tsx +102 -0
- package/src/components/primitives/Card.tsx +30 -0
- package/src/components/primitives/Divider.tsx +17 -0
- package/src/components/primitives/Icon.tsx +40 -0
- package/src/components/primitives/MarkdownText.tsx +72 -0
- package/src/components/primitives/Modal.tsx +53 -0
- package/src/components/primitives/Surface.tsx +42 -0
- package/src/components/primitives/Text.tsx +83 -0
- package/src/components/primitives/index.ts +35 -0
- package/src/components/primitives/types.ts +30 -0
- package/src/components/studio-sheet/StudioBottomSheet.tsx +114 -0
- package/src/components/studio-sheet/StudioSheetBackground.tsx +63 -0
- package/src/components/studio-sheet/StudioSheetHeader.tsx +35 -0
- package/src/components/studio-sheet/StudioSheetHeaderIconButton.tsx +109 -0
- package/src/components/studio-sheet/StudioSheetPager.tsx +66 -0
- package/src/components/studio-sheet/index.ts +18 -0
- package/src/components/studio-sheet/types.ts +5 -0
- package/src/components/utils/color.ts +25 -0
- package/src/components/utils/formatTimeAgo.ts +19 -0
- package/src/core/logger.ts +42 -0
- package/src/core/services/http/baseUrl.ts +3 -0
- package/src/core/services/http/index.ts +128 -0
- package/src/core/services/http/public.ts +33 -0
- package/src/core/services/supabase/auth.ts +41 -0
- package/src/core/services/supabase/client.ts +43 -0
- package/src/core/services/supabase/index.ts +7 -0
- package/src/data/agent/remote.ts +30 -0
- package/src/data/agent/repository.ts +34 -0
- package/src/data/agent/types.ts +28 -0
- package/src/data/apps/bundles/remote.ts +47 -0
- package/src/data/apps/bundles/repository.ts +35 -0
- package/src/data/apps/bundles/types.ts +27 -0
- package/src/data/apps/images/remote.ts +61 -0
- package/src/data/apps/images/repository.ts +47 -0
- package/src/data/apps/remote.ts +97 -0
- package/src/data/apps/repository.ts +185 -0
- package/src/data/apps/types.ts +206 -0
- package/src/data/attachment/remote.ts +32 -0
- package/src/data/attachment/repository.ts +40 -0
- package/src/data/attachment/types.ts +42 -0
- package/src/data/base-remote.ts +3 -0
- package/src/data/base-repository.ts +11 -0
- package/src/data/comments/likes/remote.ts +87 -0
- package/src/data/comments/likes/repository.ts +61 -0
- package/src/data/comments/likes/types.ts +47 -0
- package/src/data/comments/remote.ts +71 -0
- package/src/data/comments/repository.ts +53 -0
- package/src/data/comments/types.ts +60 -0
- package/src/data/github/remote.ts +23 -0
- package/src/data/github/repository.ts +35 -0
- package/src/data/github/types.ts +23 -0
- package/src/data/home/remote.ts +24 -0
- package/src/data/home/repository.ts +28 -0
- package/src/data/home/types.ts +70 -0
- package/src/data/index.ts +3 -0
- package/src/data/likes/remote.ts +57 -0
- package/src/data/likes/repository.ts +47 -0
- package/src/data/likes/types.ts +46 -0
- package/src/data/me/remote.ts +28 -0
- package/src/data/me/repository.ts +30 -0
- package/src/data/me/types.ts +14 -0
- package/src/data/merge-requests/remote.ts +76 -0
- package/src/data/merge-requests/repository.ts +66 -0
- package/src/data/merge-requests/types.ts +33 -0
- package/src/data/messages/remote.ts +21 -0
- package/src/data/messages/repository.ts +104 -0
- package/src/data/messages/types.ts +20 -0
- package/src/data/public/studio-config/remote.ts +19 -0
- package/src/data/public/studio-config/repository.ts +23 -0
- package/src/data/public/studio-config/types.ts +6 -0
- package/src/data/ratings/remote.ts +76 -0
- package/src/data/ratings/repository.ts +63 -0
- package/src/data/ratings/types.ts +57 -0
- package/src/data/threads/remote.ts +40 -0
- package/src/data/threads/repository.ts +41 -0
- package/src/data/threads/types.ts +25 -0
- package/src/data/types.ts +8 -0
- package/src/data/users/remote.ts +31 -0
- package/src/data/users/repository.ts +45 -0
- package/src/data/users/types.ts +15 -0
- package/src/index.ts +6 -0
- package/src/studio/ComergeStudio.tsx +239 -0
- package/src/studio/bootstrap/StudioBootstrap.tsx +45 -0
- package/src/studio/bootstrap/useStudioBootstrap.ts +55 -0
- package/src/studio/hooks/useApp.ts +83 -0
- package/src/studio/hooks/useAppStats.ts +111 -0
- package/src/studio/hooks/useAttachmentUpload.ts +59 -0
- package/src/studio/hooks/useBundleManager.ts +389 -0
- package/src/studio/hooks/useMergeRequests.ts +173 -0
- package/src/studio/hooks/useStudioActions.ts +96 -0
- package/src/studio/hooks/useThreadMessages.ts +85 -0
- package/src/studio/lib/chat.ts +34 -0
- package/src/studio/ui/ChatPanel.tsx +154 -0
- package/src/studio/ui/ConfirmMergeFlow.tsx +55 -0
- package/src/studio/ui/PreviewPanel.tsx +131 -0
- package/src/studio/ui/RuntimeRenderer.tsx +40 -0
- package/src/studio/ui/StudioOverlay.tsx +257 -0
- package/src/studio/ui/preview-panel/PressableCardRow.tsx +49 -0
- package/src/studio/ui/preview-panel/PreviewCollaborateSection.tsx +174 -0
- package/src/studio/ui/preview-panel/PreviewCustomizeSection.tsx +160 -0
- package/src/studio/ui/preview-panel/PreviewHeroSection.tsx +56 -0
- package/src/studio/ui/preview-panel/PreviewMetaSection.tsx +67 -0
- package/src/studio/ui/preview-panel/PreviewPanelHeader.tsx +48 -0
- package/src/studio/ui/preview-panel/SectionTitle.tsx +31 -0
- package/src/studio/ui/preview-panel/usePreviewPanelData.ts +132 -0
- package/src/studio/ui/preview-panel/utils.ts +29 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/tokens.ts +118 -0
- package/src/theme/types.ts +90 -0
- package/src/theme/useTheme.ts +11 -0
- package/dist/assets/images/merge.svg +0 -3
- package/dist/merge-72UG27QV.svg +0 -3
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { attachmentRepository } from '../../data/attachment/repository';
|
|
4
|
+
import type { AttachmentMeta } from '../../data/attachment/types';
|
|
5
|
+
|
|
6
|
+
export type UploadBase64AttachmentsParams = {
|
|
7
|
+
threadId: string;
|
|
8
|
+
appId: string;
|
|
9
|
+
dataUrls: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseAttachmentUploadResult = {
|
|
13
|
+
uploadBase64Images: (params: UploadBase64AttachmentsParams) => Promise<AttachmentMeta[]>;
|
|
14
|
+
uploading: boolean;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function useAttachmentUpload(): UseAttachmentUploadResult {
|
|
19
|
+
const [uploading, setUploading] = React.useState(false);
|
|
20
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
21
|
+
|
|
22
|
+
const uploadBase64Images = React.useCallback(async ({ threadId, appId, dataUrls }: UploadBase64AttachmentsParams) => {
|
|
23
|
+
if (!threadId || !appId) return [];
|
|
24
|
+
if (!dataUrls || dataUrls.length === 0) return [];
|
|
25
|
+
|
|
26
|
+
setUploading(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
try {
|
|
29
|
+
const blobs = await Promise.all(
|
|
30
|
+
dataUrls.map(async (dataUrl, idx) => {
|
|
31
|
+
const normalized = dataUrl.startsWith('data:') ? dataUrl : `data:image/png;base64,${dataUrl}`;
|
|
32
|
+
const resp = await fetch(normalized);
|
|
33
|
+
const blob = await resp.blob();
|
|
34
|
+
return { blob, idx };
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const files = blobs.map(({ blob }, idx) => ({
|
|
39
|
+
name: `attachment-${Date.now()}-${idx}.png`,
|
|
40
|
+
size: blob.size,
|
|
41
|
+
mimeType: blob.type || 'image/png',
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const presign = await attachmentRepository.presign({ threadId, appId, files });
|
|
45
|
+
await Promise.all(presign.uploads.map((u, index) => attachmentRepository.upload(u, blobs[index].blob)));
|
|
46
|
+
return presign.uploads.map((u) => u.attachment);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
49
|
+
setError(err);
|
|
50
|
+
throw err;
|
|
51
|
+
} finally {
|
|
52
|
+
setUploading(false);
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
return { uploadBase64Images, uploading, error };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as FileSystem from 'expo-file-system/legacy';
|
|
3
|
+
|
|
4
|
+
import type { Platform as BundlePlatform, Bundle } from '../../data/apps/bundles/types';
|
|
5
|
+
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
6
|
+
|
|
7
|
+
type BundleSource = {
|
|
8
|
+
appId: string;
|
|
9
|
+
commitId?: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseBundleManagerParams = {
|
|
13
|
+
base: BundleSource;
|
|
14
|
+
platform: BundlePlatform;
|
|
15
|
+
/**
|
|
16
|
+
* When false, we will NOT initiate/build/download the latest base bundle.
|
|
17
|
+
* We'll keep rendering whatever base bundle we already have (or hydrate from disk).
|
|
18
|
+
*
|
|
19
|
+
* Test bundles (merge request previews) are NOT gated by this.
|
|
20
|
+
*/
|
|
21
|
+
canRequestLatest?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type BundleLoadState = {
|
|
25
|
+
bundlePath: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Monotonic token to force runtime remount when the bundle file path stays the same
|
|
28
|
+
* (e.g. base bundle is replaced in-place).
|
|
29
|
+
*/
|
|
30
|
+
renderToken: number;
|
|
31
|
+
loading: boolean;
|
|
32
|
+
statusLabel: string | null;
|
|
33
|
+
error: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* True when showing a temporary/testing bundle (e.g. merge request preview).
|
|
36
|
+
*/
|
|
37
|
+
isTesting: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type UseBundleManagerResult = BundleLoadState & {
|
|
41
|
+
loadBase: () => Promise<void>;
|
|
42
|
+
loadTest: (src: BundleSource) => Promise<void>;
|
|
43
|
+
restoreBase: () => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function safeName(s: string) {
|
|
47
|
+
return s.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bundlesCacheDir(): string {
|
|
51
|
+
if (!FileSystem.cacheDirectory) {
|
|
52
|
+
throw new Error('expo-file-system cacheDirectory is not available.');
|
|
53
|
+
}
|
|
54
|
+
return `${FileSystem.cacheDirectory}comerge/bundles/`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function ensureDir(path: string) {
|
|
58
|
+
const info = await FileSystem.getInfoAsync(path);
|
|
59
|
+
if (info.exists) return;
|
|
60
|
+
await FileSystem.makeDirectoryAsync(path, { intermediates: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function baseBundleKey(appId: string, platform: BundlePlatform): string {
|
|
64
|
+
return `base:${appId}:${platform}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function testBundleKey(appId: string, commitId: string | null | undefined, platform: BundlePlatform, bundleId: string): string {
|
|
68
|
+
return `test:${appId}:${commitId ?? 'head'}:${platform}:${bundleId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toBundleFileUri(key: string): string {
|
|
72
|
+
const dir = bundlesCacheDir();
|
|
73
|
+
return `${dir}${safeName(key)}.jsbundle`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toBundleMetaFileUri(key: string): string {
|
|
77
|
+
const dir = bundlesCacheDir();
|
|
78
|
+
return `${dir}${safeName(key)}.meta.json`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type BaseBundleMeta = {
|
|
82
|
+
fingerprint: string;
|
|
83
|
+
bundleId: string;
|
|
84
|
+
checksumSha256: string | null;
|
|
85
|
+
size: number | null;
|
|
86
|
+
updatedAt: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function readJsonFile<T>(fileUri: string): Promise<T | null> {
|
|
90
|
+
try {
|
|
91
|
+
const info = await FileSystem.getInfoAsync(fileUri);
|
|
92
|
+
if (!info.exists) return null;
|
|
93
|
+
const raw = await (FileSystem as any).readAsStringAsync(fileUri);
|
|
94
|
+
if (!raw || !String(raw).trim()) return null;
|
|
95
|
+
return JSON.parse(String(raw)) as T;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function writeJsonFile(fileUri: string, value: unknown): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
await (FileSystem as any).writeAsStringAsync(fileUri, JSON.stringify(value));
|
|
104
|
+
} catch {
|
|
105
|
+
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getExistingNonEmptyFileUri(fileUri: string): Promise<string | null> {
|
|
110
|
+
try {
|
|
111
|
+
const info = await FileSystem.getInfoAsync(fileUri);
|
|
112
|
+
if (info.exists && info.size && info.size > 0) return fileUri;
|
|
113
|
+
return null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function downloadIfMissing(url: string, fileUri: string): Promise<string> {
|
|
120
|
+
const existing = await getExistingNonEmptyFileUri(fileUri);
|
|
121
|
+
if (existing) return existing;
|
|
122
|
+
const res = await FileSystem.downloadAsync(url, fileUri);
|
|
123
|
+
return res.uri;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function deleteFileIfExists(fileUri: string) {
|
|
127
|
+
try {
|
|
128
|
+
const info = await FileSystem.getInfoAsync(fileUri);
|
|
129
|
+
if (!info.exists) return;
|
|
130
|
+
await FileSystem.deleteAsync(fileUri).catch(() => {});
|
|
131
|
+
} catch {
|
|
132
|
+
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function safeReplaceFileFromUrl(url: string, targetUri: string, tmpKey: string): Promise<string> {
|
|
137
|
+
const tmpUri = toBundleFileUri(`tmp:${tmpKey}:${Date.now()}`);
|
|
138
|
+
try {
|
|
139
|
+
await FileSystem.downloadAsync(url, tmpUri);
|
|
140
|
+
const tmpOk = await getExistingNonEmptyFileUri(tmpUri);
|
|
141
|
+
if (!tmpOk) {
|
|
142
|
+
throw new Error('Downloaded bundle is empty.');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await deleteFileIfExists(targetUri);
|
|
146
|
+
await FileSystem.moveAsync({ from: tmpUri, to: targetUri });
|
|
147
|
+
|
|
148
|
+
const finalOk = await getExistingNonEmptyFileUri(targetUri);
|
|
149
|
+
if (!finalOk) throw new Error('Bundle replacement failed.');
|
|
150
|
+
return targetUri;
|
|
151
|
+
} finally {
|
|
152
|
+
await deleteFileIfExists(tmpUri);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function pollBundle(appId: string, bundleId: string, opts: { timeoutMs: number; intervalMs: number }): Promise<Bundle> {
|
|
157
|
+
const start = Date.now();
|
|
158
|
+
while (true) {
|
|
159
|
+
const bundle = await bundlesRepository.getById(appId, bundleId);
|
|
160
|
+
if (bundle.status === 'succeeded' || bundle.status === 'failed') return bundle;
|
|
161
|
+
if (Date.now() - start > opts.timeoutMs) {
|
|
162
|
+
throw new Error('Bundle build timed out.');
|
|
163
|
+
}
|
|
164
|
+
await new Promise((r) => setTimeout(r, opts.intervalMs));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveBundlePath(
|
|
169
|
+
src: BundleSource,
|
|
170
|
+
platform: BundlePlatform,
|
|
171
|
+
mode: 'base' | 'test'
|
|
172
|
+
): Promise<{ bundlePath: string; label: string; bundle: Bundle }> {
|
|
173
|
+
const { appId, commitId } = src;
|
|
174
|
+
const dir = bundlesCacheDir();
|
|
175
|
+
await ensureDir(dir);
|
|
176
|
+
|
|
177
|
+
const initiate = await bundlesRepository.initiate(appId, {
|
|
178
|
+
platform,
|
|
179
|
+
commitId: commitId ?? undefined,
|
|
180
|
+
idempotencyKey: `${appId}:${commitId ?? 'head'}:${platform}`,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const finalBundle =
|
|
184
|
+
initiate.status === 'succeeded' || initiate.status === 'failed'
|
|
185
|
+
? initiate
|
|
186
|
+
: await pollBundle(appId, initiate.id, { timeoutMs: 3 * 60 * 1000, intervalMs: 1200 });
|
|
187
|
+
|
|
188
|
+
if (finalBundle.status === 'failed') {
|
|
189
|
+
throw new Error('Bundle build failed.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const signed = await bundlesRepository.getSignedDownloadUrl(appId, finalBundle.id, { redirect: false });
|
|
193
|
+
const bundlePath =
|
|
194
|
+
mode === 'base'
|
|
195
|
+
? await safeReplaceFileFromUrl(
|
|
196
|
+
signed.url,
|
|
197
|
+
toBundleFileUri(baseBundleKey(appId, platform)),
|
|
198
|
+
`${appId}:${commitId ?? 'head'}:${platform}:${finalBundle.id}`
|
|
199
|
+
)
|
|
200
|
+
: await downloadIfMissing(signed.url, toBundleFileUri(testBundleKey(appId, commitId, platform, finalBundle.id)));
|
|
201
|
+
return { bundlePath, label: 'Ready', bundle: finalBundle };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function useBundleManager({
|
|
205
|
+
base,
|
|
206
|
+
platform,
|
|
207
|
+
canRequestLatest = true,
|
|
208
|
+
}: UseBundleManagerParams): UseBundleManagerResult {
|
|
209
|
+
const [bundlePath, setBundlePath] = React.useState<string | null>(null);
|
|
210
|
+
const [renderToken, setRenderToken] = React.useState(0);
|
|
211
|
+
const [loading, setLoading] = React.useState(false);
|
|
212
|
+
const [statusLabel, setStatusLabel] = React.useState<string | null>(null);
|
|
213
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
214
|
+
const [isTesting, setIsTesting] = React.useState(false);
|
|
215
|
+
|
|
216
|
+
const baseRef = React.useRef(base);
|
|
217
|
+
baseRef.current = base;
|
|
218
|
+
|
|
219
|
+
// Monotonic operation ids to prevent stale async loads from overwriting newer ones.
|
|
220
|
+
const baseOpIdRef = React.useRef(0);
|
|
221
|
+
const testOpIdRef = React.useRef(0);
|
|
222
|
+
const activeLoadModeRef = React.useRef<'base' | 'test' | null>(null);
|
|
223
|
+
|
|
224
|
+
const canRequestLatestRef = React.useRef<boolean>(canRequestLatest);
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
canRequestLatestRef.current = canRequestLatest;
|
|
227
|
+
if (!canRequestLatest) {
|
|
228
|
+
// Stop any in-flight base load from updating UI while the app is not ready.
|
|
229
|
+
baseOpIdRef.current += 1;
|
|
230
|
+
if (activeLoadModeRef.current === 'base') {
|
|
231
|
+
setLoading(false);
|
|
232
|
+
setStatusLabel(null);
|
|
233
|
+
activeLoadModeRef.current = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}, [canRequestLatest]);
|
|
237
|
+
// Track the most recently successfully loaded base bundle so we can instantly exit test mode.
|
|
238
|
+
const lastBaseBundlePathRef = React.useRef<string | null>(null);
|
|
239
|
+
const lastBaseFingerprintRef = React.useRef<string | null>(null);
|
|
240
|
+
// Only used to suppress an unnecessary remount on cold start when the network bundle matches the disk bundle.
|
|
241
|
+
const initialHydratedBaseFromDiskRef = React.useRef(false);
|
|
242
|
+
const hasCompletedFirstNetworkBaseLoadRef = React.useRef(false);
|
|
243
|
+
|
|
244
|
+
const hydrateBaseFromDisk = React.useCallback(
|
|
245
|
+
async (appId: string, reason: 'initial' | 'fallback') => {
|
|
246
|
+
try {
|
|
247
|
+
const dir = bundlesCacheDir();
|
|
248
|
+
await ensureDir(dir);
|
|
249
|
+
const key = baseBundleKey(appId, platform);
|
|
250
|
+
const uri = toBundleFileUri(key);
|
|
251
|
+
const existing = await getExistingNonEmptyFileUri(uri);
|
|
252
|
+
if (existing) {
|
|
253
|
+
lastBaseBundlePathRef.current = existing;
|
|
254
|
+
setBundlePath(existing);
|
|
255
|
+
const meta = await readJsonFile<BaseBundleMeta>(toBundleMetaFileUri(key));
|
|
256
|
+
if (meta?.fingerprint) {
|
|
257
|
+
lastBaseFingerprintRef.current = meta.fingerprint;
|
|
258
|
+
}
|
|
259
|
+
if (reason === 'initial') {
|
|
260
|
+
initialHydratedBaseFromDiskRef.current = true;
|
|
261
|
+
hasCompletedFirstNetworkBaseLoadRef.current = false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
[platform]
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// On cold reopen, try to load the last base bundle from disk as early as possible.
|
|
272
|
+
React.useEffect(() => {
|
|
273
|
+
if (!base.appId) return;
|
|
274
|
+
initialHydratedBaseFromDiskRef.current = false;
|
|
275
|
+
hasCompletedFirstNetworkBaseLoadRef.current = false;
|
|
276
|
+
void hydrateBaseFromDisk(base.appId, 'initial');
|
|
277
|
+
}, [base.appId, platform, hydrateBaseFromDisk]);
|
|
278
|
+
|
|
279
|
+
const activateCachedBase = React.useCallback(
|
|
280
|
+
async (appId: string) => {
|
|
281
|
+
setIsTesting(false);
|
|
282
|
+
setStatusLabel(null);
|
|
283
|
+
setError(null);
|
|
284
|
+
const cachedBase = lastBaseBundlePathRef.current;
|
|
285
|
+
if (cachedBase) {
|
|
286
|
+
setBundlePath(cachedBase);
|
|
287
|
+
} else {
|
|
288
|
+
await hydrateBaseFromDisk(appId, 'fallback');
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
[hydrateBaseFromDisk]
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const load = React.useCallback(async (src: BundleSource, mode: 'base' | 'test') => {
|
|
295
|
+
if (!src.appId) return;
|
|
296
|
+
|
|
297
|
+
const canRequestLatest = canRequestLatestRef.current;
|
|
298
|
+
if (mode === 'base' && !canRequestLatest) {
|
|
299
|
+
await activateCachedBase(src.appId);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const opId = mode === 'base' ? ++baseOpIdRef.current : ++testOpIdRef.current;
|
|
304
|
+
activeLoadModeRef.current = mode;
|
|
305
|
+
setLoading(true);
|
|
306
|
+
setError(null);
|
|
307
|
+
setStatusLabel(mode === 'test' ? 'Loading test bundle…' : 'Loading latest build…');
|
|
308
|
+
|
|
309
|
+
if (mode === 'base') {
|
|
310
|
+
void activateCachedBase(src.appId);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const { bundlePath: path, bundle } = await resolveBundlePath(src, platform, mode);
|
|
315
|
+
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
316
|
+
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
317
|
+
setBundlePath(path);
|
|
318
|
+
const fingerprint = bundle.checksumSha256 ?? `id:${bundle.id}`;
|
|
319
|
+
|
|
320
|
+
// If we started by rendering a base bundle from disk and the network "latest" bundle is the same,
|
|
321
|
+
// avoid a pointless remount (no visual flicker) ONLY for that first refresh.
|
|
322
|
+
const shouldSkipInitialBaseRemount =
|
|
323
|
+
mode === 'base' &&
|
|
324
|
+
initialHydratedBaseFromDiskRef.current &&
|
|
325
|
+
!hasCompletedFirstNetworkBaseLoadRef.current &&
|
|
326
|
+
Boolean(lastBaseFingerprintRef.current) &&
|
|
327
|
+
lastBaseFingerprintRef.current === fingerprint;
|
|
328
|
+
|
|
329
|
+
if (!shouldSkipInitialBaseRemount) {
|
|
330
|
+
setRenderToken((t) => t + 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (mode === 'base') {
|
|
334
|
+
lastBaseBundlePathRef.current = path;
|
|
335
|
+
lastBaseFingerprintRef.current = fingerprint;
|
|
336
|
+
hasCompletedFirstNetworkBaseLoadRef.current = true;
|
|
337
|
+
initialHydratedBaseFromDiskRef.current = false;
|
|
338
|
+
void writeJsonFile(toBundleMetaFileUri(baseBundleKey(src.appId, platform)), {
|
|
339
|
+
fingerprint,
|
|
340
|
+
bundleId: bundle.id,
|
|
341
|
+
checksumSha256: bundle.checksumSha256 ?? null,
|
|
342
|
+
size: bundle.size ?? null,
|
|
343
|
+
updatedAt: new Date().toISOString(),
|
|
344
|
+
} satisfies BaseBundleMeta);
|
|
345
|
+
setIsTesting(false);
|
|
346
|
+
} else {
|
|
347
|
+
setIsTesting(true);
|
|
348
|
+
}
|
|
349
|
+
setStatusLabel(null);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
352
|
+
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
353
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
354
|
+
setError(msg);
|
|
355
|
+
setStatusLabel(null);
|
|
356
|
+
} finally {
|
|
357
|
+
if (mode === 'base' && opId !== baseOpIdRef.current) return;
|
|
358
|
+
if (mode === 'test' && opId !== testOpIdRef.current) return;
|
|
359
|
+
setLoading(false);
|
|
360
|
+
if (activeLoadModeRef.current === mode) activeLoadModeRef.current = null;
|
|
361
|
+
}
|
|
362
|
+
}, [activateCachedBase, platform]);
|
|
363
|
+
|
|
364
|
+
const loadBase = React.useCallback(async () => {
|
|
365
|
+
await load(baseRef.current, 'base');
|
|
366
|
+
}, [load]);
|
|
367
|
+
|
|
368
|
+
const loadTest = React.useCallback(async (src: BundleSource) => {
|
|
369
|
+
await load(src, 'test');
|
|
370
|
+
}, [load]);
|
|
371
|
+
|
|
372
|
+
const restoreBase = React.useCallback(async () => {
|
|
373
|
+
const src = baseRef.current;
|
|
374
|
+
if (!src.appId) return;
|
|
375
|
+
await activateCachedBase(src.appId);
|
|
376
|
+
if (canRequestLatestRef.current) {
|
|
377
|
+
await load(src, 'base');
|
|
378
|
+
}
|
|
379
|
+
}, [activateCachedBase, load]);
|
|
380
|
+
|
|
381
|
+
React.useEffect(() => {
|
|
382
|
+
if (!canRequestLatest) return;
|
|
383
|
+
void loadBase();
|
|
384
|
+
}, [base.appId, base.commitId, platform, canRequestLatest, loadBase]);
|
|
385
|
+
|
|
386
|
+
return { bundlePath, renderToken, loading, statusLabel, error, isTesting, loadBase, loadTest, restoreBase };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { MergeRequest, MergeRequestStatus } from '../../data/merge-requests/types';
|
|
4
|
+
import { mergeRequestsRepository } from '../../data/merge-requests/repository';
|
|
5
|
+
import type { MergeRequestSummary } from '../../components/models/types';
|
|
6
|
+
import { usersRepository } from '../../data/users/repository';
|
|
7
|
+
import type { UserStats } from '../../data/users/types';
|
|
8
|
+
|
|
9
|
+
export type MergeRequestLists = {
|
|
10
|
+
/**
|
|
11
|
+
* Merge requests targeting the current app (for owners/reviewers).
|
|
12
|
+
*/
|
|
13
|
+
incoming: MergeRequest[];
|
|
14
|
+
/**
|
|
15
|
+
* Merge requests created from the current app (for contributors).
|
|
16
|
+
*/
|
|
17
|
+
outgoing: MergeRequest[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MergeRequestActions = {
|
|
21
|
+
refresh: () => Promise<void>;
|
|
22
|
+
openMergeRequest: (sourceAppId: string) => Promise<MergeRequest>;
|
|
23
|
+
approve: (mrId: string) => Promise<MergeRequest>;
|
|
24
|
+
reject: (mrId: string) => Promise<MergeRequest>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UseMergeRequestsResult = {
|
|
28
|
+
loading: boolean;
|
|
29
|
+
error: Error | null;
|
|
30
|
+
lists: MergeRequestLists;
|
|
31
|
+
actions: MergeRequestActions;
|
|
32
|
+
toSummary: (mr: MergeRequest) => MergeRequestSummary;
|
|
33
|
+
byId: Record<string, MergeRequest>;
|
|
34
|
+
creatorStatsById: Record<string, UserStats>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const incomingStatuses: MergeRequestStatus[] = ['open', 'approved'];
|
|
38
|
+
const outgoingStatuses: MergeRequestStatus[] = ['open', 'approved', 'rejected', 'merged', 'closed'];
|
|
39
|
+
|
|
40
|
+
function toUiStatus(status: MergeRequestStatus): MergeRequestSummary['status'] {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case 'open':
|
|
43
|
+
case 'approved':
|
|
44
|
+
case 'rejected':
|
|
45
|
+
case 'merged':
|
|
46
|
+
return status;
|
|
47
|
+
case 'closed':
|
|
48
|
+
default:
|
|
49
|
+
return 'rejected';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useMergeRequests(params: { appId: string }): UseMergeRequestsResult {
|
|
54
|
+
const { appId } = params;
|
|
55
|
+
const [incoming, setIncoming] = React.useState<MergeRequest[]>([]);
|
|
56
|
+
const [outgoing, setOutgoing] = React.useState<MergeRequest[]>([]);
|
|
57
|
+
const [loading, setLoading] = React.useState(false);
|
|
58
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
59
|
+
const [creatorStatsById, setCreatorStatsById] = React.useState<Record<string, UserStats>>({});
|
|
60
|
+
|
|
61
|
+
const pollUntilMerged = React.useCallback(async (mrId: string) => {
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
const timeoutMs = 2 * 60 * 1000;
|
|
64
|
+
for (;;) {
|
|
65
|
+
const mr = await mergeRequestsRepository.getById(mrId);
|
|
66
|
+
if (mr.status === 'merged') return mr;
|
|
67
|
+
if (Date.now() - startedAt > timeoutMs) return mr;
|
|
68
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
69
|
+
}
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const refresh = React.useCallback(async () => {
|
|
73
|
+
if (!appId) {
|
|
74
|
+
setIncoming([]);
|
|
75
|
+
setOutgoing([]);
|
|
76
|
+
setCreatorStatsById({});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setLoading(true);
|
|
80
|
+
setError(null);
|
|
81
|
+
try {
|
|
82
|
+
const [inc, out] = await Promise.all([
|
|
83
|
+
mergeRequestsRepository.listByStatuses({ targetAppId: appId, statuses: incomingStatuses }).then((x) => {
|
|
84
|
+
return (Object.values(x).flat() as MergeRequest[]).filter(Boolean);
|
|
85
|
+
}),
|
|
86
|
+
mergeRequestsRepository.listByStatuses({ sourceAppId: appId, statuses: outgoingStatuses }).then((x) => {
|
|
87
|
+
return (Object.values(x).flat() as MergeRequest[]).filter(Boolean);
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
setIncoming(inc);
|
|
91
|
+
setOutgoing(out);
|
|
92
|
+
|
|
93
|
+
const ids = Array.from(new Set([...inc, ...out].map((m) => m.createdBy).filter(Boolean)));
|
|
94
|
+
if (ids.length === 0) {
|
|
95
|
+
setCreatorStatsById({});
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
const map = await usersRepository.getStatsBatch(ids);
|
|
99
|
+
setCreatorStatsById(map);
|
|
100
|
+
} catch {
|
|
101
|
+
// Keep whatever we already have.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
106
|
+
setIncoming([]);
|
|
107
|
+
setOutgoing([]);
|
|
108
|
+
setCreatorStatsById({});
|
|
109
|
+
} finally {
|
|
110
|
+
setLoading(false);
|
|
111
|
+
}
|
|
112
|
+
}, [appId]);
|
|
113
|
+
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
void refresh();
|
|
116
|
+
}, [refresh]);
|
|
117
|
+
|
|
118
|
+
const openMergeRequest = React.useCallback(async (sourceAppId: string) => {
|
|
119
|
+
const mr = await mergeRequestsRepository.open({ sourceAppId });
|
|
120
|
+
await refresh();
|
|
121
|
+
return mr;
|
|
122
|
+
}, [refresh]);
|
|
123
|
+
|
|
124
|
+
const approve = React.useCallback(async (mrId: string) => {
|
|
125
|
+
const mr = await mergeRequestsRepository.update(mrId, { status: 'approved' });
|
|
126
|
+
await refresh();
|
|
127
|
+
const merged = await pollUntilMerged(mrId);
|
|
128
|
+
await refresh();
|
|
129
|
+
return merged ?? mr;
|
|
130
|
+
}, [pollUntilMerged, refresh]);
|
|
131
|
+
|
|
132
|
+
const reject = React.useCallback(async (mrId: string) => {
|
|
133
|
+
const mr = await mergeRequestsRepository.update(mrId, { status: 'rejected' });
|
|
134
|
+
await refresh();
|
|
135
|
+
return mr;
|
|
136
|
+
}, [refresh]);
|
|
137
|
+
|
|
138
|
+
const toSummary = React.useCallback((mr: MergeRequest): MergeRequestSummary => {
|
|
139
|
+
const stats = creatorStatsById[mr.createdBy];
|
|
140
|
+
return {
|
|
141
|
+
id: mr.id,
|
|
142
|
+
title: mr.title ?? undefined,
|
|
143
|
+
description: mr.description ?? undefined,
|
|
144
|
+
status: toUiStatus(mr.status),
|
|
145
|
+
creator: {
|
|
146
|
+
id: mr.createdBy,
|
|
147
|
+
name: stats?.name ?? undefined,
|
|
148
|
+
avatarUri: stats?.avatar ?? undefined,
|
|
149
|
+
},
|
|
150
|
+
createdAt: mr.createdAt,
|
|
151
|
+
updatedAt: mr.updatedAt,
|
|
152
|
+
};
|
|
153
|
+
}, [creatorStatsById]);
|
|
154
|
+
|
|
155
|
+
const byId = React.useMemo(() => {
|
|
156
|
+
const all = [...incoming, ...outgoing];
|
|
157
|
+
const map: Record<string, MergeRequest> = {};
|
|
158
|
+
for (const mr of all) map[mr.id] = mr;
|
|
159
|
+
return map;
|
|
160
|
+
}, [incoming, outgoing]);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
loading,
|
|
164
|
+
error,
|
|
165
|
+
lists: { incoming, outgoing },
|
|
166
|
+
actions: { refresh, openMergeRequest, approve, reject },
|
|
167
|
+
toSummary,
|
|
168
|
+
byId,
|
|
169
|
+
creatorStatsById,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|