Spaces:
Running
Running
| // Descarga un repo PÚBLICO (GitHub/Bitbucket) en una carpeta real elegida por | |
| // el usuario, para abrirlo como proyecto sin necesitar git instalado. Mismo | |
| // patrón que skills.js: árbol de Git vía API + contenidos por raw CDN (sin | |
| // tope de la API, solo la llamada al árbol cuenta contra el límite 60/h). | |
| // | |
| // RESUMIBLE por diseño: listRepo() (el árbol) y downloadFiles() (los ficheros) | |
| // están separados a propósito — quien llama (main.js) persiste la lista y qué | |
| // rutas ya se descargaron, así una descarga interrumpida (recarga, pestaña | |
| // cerrada) continúa donde se quedó en vez de volver a empezar. | |
| const MAX_FILES = 4000; | |
| const MAX_FILE_BYTES = 4 * 1024 * 1024; // ficheros más grandes se saltan (binarios enormes) | |
| const CONCURRENCY = 10; | |
| export function parseRepoUrl(raw) { | |
| const url = String(raw || '').trim().replace(/\.git$/, '').replace(/\/+$/, ''); | |
| let m = url.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)(?:\/tree\/([\w./-]+))?/i); | |
| if (m) return { host: 'github', owner: m[1], repo: m[2], branch: m[3] || null }; | |
| m = url.match(/^(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([\w.-]+)\/([\w.-]+)(?:\/src\/([\w./-]+))?/i); | |
| if (m) return { host: 'bitbucket', owner: m[1], repo: m[2], branch: m[3] || null }; | |
| throw new Error('URL no reconocida. Usa github.com/usuario/repo o bitbucket.org/usuario/repo'); | |
| } | |
| // escribe un fichero (texto o binario) en `root`, creando subcarpetas | |
| async function writeInto(root, path, blob) { | |
| const parts = path.split('/'); const name = parts.pop(); | |
| let dir = root; | |
| for (const seg of parts) dir = await dir.getDirectoryHandle(seg, { create: true }); | |
| const fh = await dir.getFileHandle(name, { create: true }); | |
| const w = await fh.createWritable(); | |
| await w.write(blob); | |
| await w.close(); | |
| } | |
| // pool de concurrencia simple: corre `items` a través de `worker`, máx N a la vez | |
| async function pool(items, n, worker) { | |
| let i = 0; | |
| const runners = Array.from({ length: Math.min(n, items.length) }, async () => { | |
| while (i < items.length) await worker(items[i++]); | |
| }); | |
| await Promise.all(runners); | |
| } | |
| // ── listar (una sola vez, resultado persistible) ───────────────────────── | |
| async function listGithubFiles({ owner, repo, branch }) { | |
| if (!branch) { | |
| const r = await fetch(`https://api.github.com/repos/${owner}/${repo}`); | |
| if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.'); | |
| if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`); | |
| branch = (await r.json()).default_branch || 'main'; | |
| } | |
| let tree = null; | |
| for (const b of [branch, 'main', 'master']) { | |
| const r = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${b}?recursive=1`); | |
| if (r.ok) { tree = (await r.json()).tree || []; branch = b; break; } | |
| if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.'); | |
| } | |
| if (!tree) throw new Error('No pude leer el árbol del repo'); | |
| const blobs = tree.filter(n => n.type === 'blob'); | |
| const files = blobs.filter(n => !n.size || n.size <= MAX_FILE_BYTES).slice(0, MAX_FILES).map(n => ({ path: n.path, size: n.size || 0 })); | |
| return { branch, files, skipped: blobs.length - files.length }; | |
| } | |
| // Bitbucket no tiene un único endpoint de árbol recursivo: se camina por directorios. | |
| async function bitbucketWalk(owner, repo, branch, path, files) { | |
| if (files.length >= MAX_FILES) return; | |
| let url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${path}?pagelen=100`; | |
| while (url && files.length < MAX_FILES) { | |
| const r = await fetch(url); | |
| if (!r.ok) throw new Error(`Bitbucket: HTTP ${r.status} (¿repo privado o rama incorrecta?)`); | |
| const data = await r.json(); | |
| for (const e of data.values || []) { | |
| if (files.length >= MAX_FILES) break; | |
| if (e.type === 'commit_directory') await bitbucketWalk(owner, repo, branch, e.path, files); | |
| else if (e.type === 'commit_file' && (!e.size || e.size <= MAX_FILE_BYTES)) files.push({ path: e.path, size: e.size || 0 }); | |
| } | |
| url = data.next || null; | |
| } | |
| } | |
| async function listBitbucketFiles({ owner, repo, branch }) { | |
| if (!branch) { | |
| const r = await fetch(`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`); | |
| if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`); | |
| branch = (await r.json()).mainbranch?.name || 'main'; | |
| } | |
| const files = []; | |
| await bitbucketWalk(owner, repo, branch, '', files); | |
| return { branch, files, skipped: 0 }; | |
| } | |
| // Lista el repo completo UNA vez. Devuelve todo lo necesario para persistir | |
| // el trabajo: { host, owner, repo, branch, files:[{path,size}], skipped }. | |
| export async function listRepo(url) { | |
| const info = parseRepoUrl(url); | |
| const { branch, files, skipped } = info.host === 'github' ? await listGithubFiles(info) : await listBitbucketFiles(info); | |
| if (!files.length) throw new Error('El repo está vacío (o no pude listarlo)'); | |
| return { host: info.host, owner: info.owner, repo: info.repo, branch, files, skipped }; | |
| } | |
| // Descarga los ficheros de `job.files` que NO estén en `doneSet` (Set de | |
| // rutas). Llama a onFileDone(path) tras cada escritura para que quien invoque | |
| // persista el progreso — así una interrupción se retoma sin re-descargar nada. | |
| export async function downloadFiles(job, targetHandle, doneSet, onProgress, onFileDone) { | |
| const { host, owner, repo, branch, files } = job; | |
| const pending = files.filter(f => !doneSet.has(f.path)); | |
| let done = files.length - pending.length; | |
| onProgress?.({ text: `${done}/${files.length}`, done, total: files.length }); | |
| await pool(pending, CONCURRENCY, async f => { | |
| onProgress?.({ text: `Descargando ${f.path}…`, done, total: files.length }); | |
| const url = host === 'github' | |
| ? `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${f.path}` | |
| : `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${f.path}`; | |
| const r = await fetch(url); | |
| if (r.ok) await writeInto(targetHandle, f.path, await r.blob()); | |
| done++; | |
| onFileDone?.(f.path); | |
| onProgress?.({ text: `${done}/${files.length}`, done, total: files.length }); | |
| }); | |
| } | |
| // Conveniencia: listar + descargar todo de una vez (sin resumibilidad; para | |
| // quien no necesite persistir progreso entre sesiones). | |
| export async function cloneToHandle(url, targetHandle, onProgress) { | |
| const job = await listRepo(url); | |
| await downloadFiles(job, targetHandle, new Set(), onProgress); | |
| return { count: job.files.length, skipped: job.skipped }; | |
| } | |