elffuss-code / js /tools /clone.js
KikoCis's picture
Elffuss Code — browser IDE with a local AI agent (WebGPU)
b30b7c5 verified
Raw
History Blame Contribute Delete
6.67 kB
// 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 };
}