@illuma-ai/code-sandbox 1.0.0
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/LICENSE +15 -0
- package/dist/__sw__.js +712 -0
- package/dist/code-sandbox.css +1 -0
- package/dist/components/BootOverlay.d.ts +17 -0
- package/dist/components/CodeEditor.d.ts +11 -0
- package/dist/components/FileTree.d.ts +19 -0
- package/dist/components/Preview.d.ts +15 -0
- package/dist/components/Terminal.d.ts +15 -0
- package/dist/components/ViewSlider.d.ts +25 -0
- package/dist/components/Workbench.d.ts +28 -0
- package/dist/hooks/useRuntime.d.ts +25 -0
- package/dist/index.cjs +50074 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +77335 -0
- package/dist/index.js.map +1 -0
- package/dist/services/git.d.ts +57 -0
- package/dist/services/runtime.d.ts +119 -0
- package/dist/templates/fullstack-starter.d.ts +38 -0
- package/dist/templates/index.d.ts +38 -0
- package/dist/types.d.ts +137 -0
- package/package.json +69 -0
- package/src/components/BootOverlay.tsx +145 -0
- package/src/components/CodeEditor.tsx +168 -0
- package/src/components/FileTree.tsx +286 -0
- package/src/components/Preview.tsx +50 -0
- package/src/components/Terminal.tsx +68 -0
- package/src/components/ViewSlider.tsx +87 -0
- package/src/components/Workbench.tsx +301 -0
- package/src/hooks/useRuntime.ts +236 -0
- package/src/index.ts +48 -0
- package/src/services/git.ts +415 -0
- package/src/services/runtime.ts +536 -0
- package/src/styles.css +24 -0
- package/src/templates/fullstack-starter.ts +3297 -0
- package/src/templates/index.ts +607 -0
- package/src/types.ts +179 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitService — GitHub API integration for loading and saving project files.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* - Clone: GET repo tree → GET file contents → return FileMap
|
|
6
|
+
* - Commit: POST branch → PUT files → POST PR
|
|
7
|
+
*
|
|
8
|
+
* All operations use the GitHub REST API (no git CLI needed).
|
|
9
|
+
* Works entirely in the browser — no server required.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
FileMap,
|
|
14
|
+
GitCommitRequest,
|
|
15
|
+
GitHubRepo,
|
|
16
|
+
GitPRRequest,
|
|
17
|
+
GitPRResult,
|
|
18
|
+
} from "../types";
|
|
19
|
+
|
|
20
|
+
const GITHUB_API = "https://api.github.com";
|
|
21
|
+
|
|
22
|
+
/** File extensions to treat as text (others are skipped) */
|
|
23
|
+
const TEXT_EXTENSIONS = new Set([
|
|
24
|
+
".js",
|
|
25
|
+
".jsx",
|
|
26
|
+
".ts",
|
|
27
|
+
".tsx",
|
|
28
|
+
".json",
|
|
29
|
+
".html",
|
|
30
|
+
".htm",
|
|
31
|
+
".css",
|
|
32
|
+
".scss",
|
|
33
|
+
".sass",
|
|
34
|
+
".less",
|
|
35
|
+
".md",
|
|
36
|
+
".mdx",
|
|
37
|
+
".txt",
|
|
38
|
+
".yml",
|
|
39
|
+
".yaml",
|
|
40
|
+
".toml",
|
|
41
|
+
".xml",
|
|
42
|
+
".svg",
|
|
43
|
+
".env",
|
|
44
|
+
".gitignore",
|
|
45
|
+
".eslintrc",
|
|
46
|
+
".prettierrc",
|
|
47
|
+
".sh",
|
|
48
|
+
".bash",
|
|
49
|
+
".zsh",
|
|
50
|
+
".py",
|
|
51
|
+
".rb",
|
|
52
|
+
".go",
|
|
53
|
+
".rs",
|
|
54
|
+
".java",
|
|
55
|
+
".kt",
|
|
56
|
+
".c",
|
|
57
|
+
".cpp",
|
|
58
|
+
".h",
|
|
59
|
+
".hpp",
|
|
60
|
+
".cs",
|
|
61
|
+
".php",
|
|
62
|
+
".sql",
|
|
63
|
+
".graphql",
|
|
64
|
+
".lock",
|
|
65
|
+
".mjs",
|
|
66
|
+
".cjs",
|
|
67
|
+
".mts",
|
|
68
|
+
".cts",
|
|
69
|
+
".vue",
|
|
70
|
+
".svelte",
|
|
71
|
+
".astro",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Directories to skip when cloning */
|
|
75
|
+
const SKIP_DIRS = new Set([
|
|
76
|
+
"node_modules",
|
|
77
|
+
".git",
|
|
78
|
+
"dist",
|
|
79
|
+
"build",
|
|
80
|
+
".next",
|
|
81
|
+
".cache",
|
|
82
|
+
"__pycache__",
|
|
83
|
+
".venv",
|
|
84
|
+
"venv",
|
|
85
|
+
"coverage",
|
|
86
|
+
".nyc_output",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/** Max file size to fetch (skip large files) */
|
|
90
|
+
const MAX_FILE_SIZE = 500_000; // 500KB
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* GitHub API service for cloning repos and creating PRs.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* const git = new GitService('ghp_xxxxx');
|
|
98
|
+
* const files = await git.cloneRepo({ owner: 'user', repo: 'my-app' });
|
|
99
|
+
* // ... user edits files ...
|
|
100
|
+
* const pr = await git.createPR({
|
|
101
|
+
* owner: 'user', repo: 'my-app',
|
|
102
|
+
* branch: 'sandbox/changes', changes: editedFiles,
|
|
103
|
+
* message: 'Update from sandbox', title: 'Sandbox changes',
|
|
104
|
+
* });
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export class GitService {
|
|
108
|
+
private token: string;
|
|
109
|
+
|
|
110
|
+
constructor(token: string) {
|
|
111
|
+
this.token = token;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
// Clone: fetch repo files as a FileMap
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clone a GitHub repository into a flat file map.
|
|
120
|
+
*
|
|
121
|
+
* Uses the Git Trees API for a single request to get the full file listing,
|
|
122
|
+
* then fetches text file contents in parallel batches.
|
|
123
|
+
*
|
|
124
|
+
* @param repo - GitHub repository coordinates
|
|
125
|
+
* @param onProgress - Optional progress callback (0-100)
|
|
126
|
+
* @returns FileMap of path → content
|
|
127
|
+
*/
|
|
128
|
+
async cloneRepo(
|
|
129
|
+
repo: GitHubRepo,
|
|
130
|
+
onProgress?: (percent: number, message: string) => void,
|
|
131
|
+
): Promise<FileMap> {
|
|
132
|
+
const { owner, repo: repoName, branch = "main", path: subPath } = repo;
|
|
133
|
+
|
|
134
|
+
onProgress?.(5, "Fetching repository structure...");
|
|
135
|
+
|
|
136
|
+
// 1. Get the tree (recursive) for the branch
|
|
137
|
+
const treeUrl = `${GITHUB_API}/repos/${owner}/${repoName}/git/trees/${branch}?recursive=1`;
|
|
138
|
+
const treeRes = await this.fetch(treeUrl);
|
|
139
|
+
const treeData = await treeRes.json();
|
|
140
|
+
|
|
141
|
+
if (!treeData.tree) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Failed to fetch repo tree: ${treeData.message || "Unknown error"}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. Filter to text files, skip large/binary/excluded dirs
|
|
148
|
+
const filesToFetch: Array<{ path: string; sha: string; size: number }> = [];
|
|
149
|
+
|
|
150
|
+
for (const item of treeData.tree) {
|
|
151
|
+
if (item.type !== "blob") {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apply subPath filter if specified
|
|
156
|
+
if (subPath && !item.path.startsWith(subPath)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Skip excluded directories
|
|
161
|
+
const parts = item.path.split("/");
|
|
162
|
+
if (parts.some((p: string) => SKIP_DIRS.has(p))) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Skip files that are too large
|
|
167
|
+
if (item.size > MAX_FILE_SIZE) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only include text files
|
|
172
|
+
const ext = "." + item.path.split(".").pop()?.toLowerCase();
|
|
173
|
+
const basename = parts[parts.length - 1];
|
|
174
|
+
// Include files with known extensions, dotfiles, or extensionless config files
|
|
175
|
+
if (
|
|
176
|
+
TEXT_EXTENSIONS.has(ext) ||
|
|
177
|
+
basename.startsWith(".") ||
|
|
178
|
+
!basename.includes(".")
|
|
179
|
+
) {
|
|
180
|
+
filesToFetch.push({ path: item.path, sha: item.sha, size: item.size });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
onProgress?.(15, `Found ${filesToFetch.length} files to fetch...`);
|
|
185
|
+
|
|
186
|
+
// 3. Fetch file contents in parallel batches
|
|
187
|
+
const files: FileMap = {};
|
|
188
|
+
const batchSize = 20;
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < filesToFetch.length; i += batchSize) {
|
|
191
|
+
const batch = filesToFetch.slice(i, i + batchSize);
|
|
192
|
+
|
|
193
|
+
const results = await Promise.all(
|
|
194
|
+
batch.map(async (file) => {
|
|
195
|
+
try {
|
|
196
|
+
const contentUrl = `${GITHUB_API}/repos/${owner}/${repoName}/contents/${file.path}?ref=${branch}`;
|
|
197
|
+
const res = await this.fetch(contentUrl);
|
|
198
|
+
const data = await res.json();
|
|
199
|
+
|
|
200
|
+
if (data.encoding === "base64" && data.content) {
|
|
201
|
+
const content = atob(data.content.replace(/\n/g, ""));
|
|
202
|
+
// Strip subPath prefix if specified
|
|
203
|
+
const relativePath = subPath
|
|
204
|
+
? file.path.replace(new RegExp(`^${subPath}/?`), "")
|
|
205
|
+
: file.path;
|
|
206
|
+
return { path: relativePath, content };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
for (const result of results) {
|
|
216
|
+
if (result) {
|
|
217
|
+
files[result.path] = result.content;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const percent =
|
|
222
|
+
15 + Math.round(((i + batch.length) / filesToFetch.length) * 80);
|
|
223
|
+
onProgress?.(
|
|
224
|
+
percent,
|
|
225
|
+
`Fetching files... (${Math.min(i + batchSize, filesToFetch.length)}/${filesToFetch.length})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
onProgress?.(100, `Loaded ${Object.keys(files).length} files`);
|
|
230
|
+
|
|
231
|
+
return files;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
// Commit + PR: write changes back to GitHub
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Create a new branch with file changes and open a pull request.
|
|
240
|
+
*
|
|
241
|
+
* Steps:
|
|
242
|
+
* 1. Get the base branch's HEAD SHA
|
|
243
|
+
* 2. Create a new branch from that SHA
|
|
244
|
+
* 3. For each changed file, update its contents on the new branch
|
|
245
|
+
* 4. Create a pull request
|
|
246
|
+
*/
|
|
247
|
+
async createPR(request: GitPRRequest): Promise<GitPRResult> {
|
|
248
|
+
const {
|
|
249
|
+
owner,
|
|
250
|
+
repo,
|
|
251
|
+
branch,
|
|
252
|
+
baseBranch = "main",
|
|
253
|
+
changes,
|
|
254
|
+
message,
|
|
255
|
+
title,
|
|
256
|
+
body,
|
|
257
|
+
} = request;
|
|
258
|
+
|
|
259
|
+
// 1. Get base branch HEAD SHA
|
|
260
|
+
const refRes = await this.fetch(
|
|
261
|
+
`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
|
|
262
|
+
);
|
|
263
|
+
const refData = await refRes.json();
|
|
264
|
+
const baseSha = refData.object.sha;
|
|
265
|
+
|
|
266
|
+
// 2. Create new branch
|
|
267
|
+
await this.fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
ref: `refs/heads/${branch}`,
|
|
271
|
+
sha: baseSha,
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// 3. Commit each changed file
|
|
276
|
+
for (const [path, content] of Object.entries(changes)) {
|
|
277
|
+
// Get current file SHA (if it exists)
|
|
278
|
+
let fileSha: string | undefined;
|
|
279
|
+
try {
|
|
280
|
+
const fileRes = await this.fetch(
|
|
281
|
+
`${GITHUB_API}/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
282
|
+
);
|
|
283
|
+
const fileData = await fileRes.json();
|
|
284
|
+
fileSha = fileData.sha;
|
|
285
|
+
} catch {
|
|
286
|
+
// File doesn't exist yet — that's fine, we'll create it
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await this.fetch(
|
|
290
|
+
`${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`,
|
|
291
|
+
{
|
|
292
|
+
method: "PUT",
|
|
293
|
+
body: JSON.stringify({
|
|
294
|
+
message,
|
|
295
|
+
content: btoa(content),
|
|
296
|
+
branch,
|
|
297
|
+
...(fileSha ? { sha: fileSha } : {}),
|
|
298
|
+
}),
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 4. Create pull request
|
|
304
|
+
const prRes = await this.fetch(
|
|
305
|
+
`${GITHUB_API}/repos/${owner}/${repo}/pulls`,
|
|
306
|
+
{
|
|
307
|
+
method: "POST",
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
title,
|
|
310
|
+
body:
|
|
311
|
+
body ||
|
|
312
|
+
`Changes from code sandbox.\n\nModified files:\n${Object.keys(
|
|
313
|
+
changes,
|
|
314
|
+
)
|
|
315
|
+
.map((p) => `- ${p}`)
|
|
316
|
+
.join("\n")}`,
|
|
317
|
+
head: branch,
|
|
318
|
+
base: baseBranch,
|
|
319
|
+
}),
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const prData = await prRes.json();
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
number: prData.number,
|
|
327
|
+
url: prData.html_url,
|
|
328
|
+
branch,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Commit changes without creating a PR.
|
|
334
|
+
*/
|
|
335
|
+
async commit(request: GitCommitRequest): Promise<string> {
|
|
336
|
+
const {
|
|
337
|
+
owner,
|
|
338
|
+
repo,
|
|
339
|
+
branch,
|
|
340
|
+
baseBranch = "main",
|
|
341
|
+
changes,
|
|
342
|
+
message,
|
|
343
|
+
} = request;
|
|
344
|
+
|
|
345
|
+
// Get base SHA
|
|
346
|
+
const refRes = await this.fetch(
|
|
347
|
+
`${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
|
|
348
|
+
);
|
|
349
|
+
const refData = await refRes.json();
|
|
350
|
+
const baseSha = refData.object.sha;
|
|
351
|
+
|
|
352
|
+
// Create branch (may already exist)
|
|
353
|
+
try {
|
|
354
|
+
await this.fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }),
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
// Branch already exists — continue
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Commit files
|
|
363
|
+
for (const [path, content] of Object.entries(changes)) {
|
|
364
|
+
let fileSha: string | undefined;
|
|
365
|
+
try {
|
|
366
|
+
const fileRes = await this.fetch(
|
|
367
|
+
`${GITHUB_API}/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
|
368
|
+
);
|
|
369
|
+
const fileData = await fileRes.json();
|
|
370
|
+
fileSha = fileData.sha;
|
|
371
|
+
} catch {
|
|
372
|
+
// New file
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
await this.fetch(
|
|
376
|
+
`${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`,
|
|
377
|
+
{
|
|
378
|
+
method: "PUT",
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
message,
|
|
381
|
+
content: btoa(content),
|
|
382
|
+
branch,
|
|
383
|
+
...(fileSha ? { sha: fileSha } : {}),
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return branch;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// -------------------------------------------------------------------------
|
|
393
|
+
// Private
|
|
394
|
+
// -------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
/** Fetch with auth headers */
|
|
397
|
+
private async fetch(url: string, init?: RequestInit): Promise<Response> {
|
|
398
|
+
const res = await fetch(url, {
|
|
399
|
+
...init,
|
|
400
|
+
headers: {
|
|
401
|
+
Authorization: `Bearer ${this.token}`,
|
|
402
|
+
Accept: "application/vnd.github.v3+json",
|
|
403
|
+
"Content-Type": "application/json",
|
|
404
|
+
...(init?.headers || {}),
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (!res.ok) {
|
|
409
|
+
const body = await res.text();
|
|
410
|
+
throw new Error(`GitHub API error ${res.status}: ${body}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return res;
|
|
414
|
+
}
|
|
415
|
+
}
|