File size: 6,674 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// 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 };
}