/** * Kanbante — sélecteur de dépôt de la Capture éclair (0.21.0). * * Le champ Dépôt devient intelligent : * - suggestions des dépôts réels dès la frappe (données /api/repos, * épinglés et récents d'abord) ; * - correspondance sur les deux segments : « kan » suffit pour * trouver Projets/kanbante, « pr/kan » aussi ; * - complétion Tab segment par segment, comme un shell : * « pr » Tab → « Projets/ », puis « kan » Tab → « kanbante » ; * - ↑/↓ + Entrée, clic souris, Échap ; * - avertissement doux si le nom final n'existe pas dans Gitea. */ (function () { var input = document.getElementById('capture-repo'); if (!input) return; var list = document.getElementById('repo-listbox'); var hint = document.getElementById('repo-hint'); var repos = null; var candidates = []; var activeIndex = -1; var isOpen = false; function load() { if (repos) return Promise.resolve(repos); return fetch('/api/repos', { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.json(); }) .then(function (data) { repos = data.repos || []; return repos; }) .catch(function () { repos = []; return repos; }); } /* ── Correspondance ──────────────────────────────────── */ function matchRepos(value) { var raw = value.trim().toLowerCase(); if (!raw) return repos.slice(0, 12); var parts = raw.split('/').map(function (s) { return s.trim(); }); var scored = []; repos.forEach(function (repo) { var full = repo.full_name.toLowerCase(); var slash = full.indexOf('/'); var owner = full.slice(0, slash); var name = full.slice(slash + 1); var score = null; if (parts.length === 1) { var q = parts[0]; if (!q) score = 4; else if (name.indexOf(q) === 0) score = 0; else if (owner.indexOf(q) === 0) score = 1; else if (name.indexOf(q) > -1) score = 2; else if (full.indexOf(q) > -1) score = 3; } else { if (owner.indexOf(parts[0]) === 0 && name.indexOf(parts[1]) === 0) score = 0; else if (owner.indexOf(parts[0]) > -1 && name.indexOf(parts[1]) > -1) score = 1; } if (score !== null) scored.push({ repo: repo, score: score }); }); scored.sort(function (a, b) { if (a.score !== b.score) return a.score - b.score; if (a.repo.starred !== b.repo.starred) return a.repo.starred ? -1 : 1; return (b.repo.updated_at || '').localeCompare(a.repo.updated_at || ''); }); return scored.slice(0, 12).map(function (s) { return s.repo; }); } /* ── Rendu / ouverture ───────────────────────────────── */ function highlight() { Array.prototype.forEach.call(list.children, function (item, i) { item.classList.toggle('active', i === activeIndex); }); } function render() { list.innerHTML = ''; candidates.forEach(function (repo, i) { var item = document.createElement('span'); item.className = 'repo-item'; item.setAttribute('role', 'option'); var star = repo.starred ? '⭐ ' : ''; var date = repo.updated_at ? repo.updated_at.slice(0, 10) : ''; var nameEl = document.createElement('span'); nameEl.className = 'repo-item-name'; nameEl.textContent = star + repo.full_name; var metaEl = document.createElement('span'); metaEl.className = 'repo-item-meta'; metaEl.textContent = (repo.archived ? 'archivé · ' : '') + date; item.appendChild(nameEl); item.appendChild(metaEl); item.addEventListener('mousedown', function (e) { e.preventDefault(); choose(repo); }); item.addEventListener('mousemove', function () { if (activeIndex !== i) { activeIndex = i; highlight(); } }); list.appendChild(item); }); highlight(); list.hidden = !isOpen || candidates.length === 0; input.setAttribute('aria-expanded', isOpen && candidates.length ? 'true' : 'false'); } function refresh(openList) { if (!repos) return; candidates = matchRepos(input.value); if (activeIndex >= candidates.length) activeIndex = candidates.length - 1; isOpen = openList; render(); validate(); } function close() { isOpen = false; activeIndex = -1; render(); } function choose(repo) { input.value = repo.full_name; close(); validate(); input.focus(); } function validate() { if (!hint || !repos) return; var value = input.value.trim().toLowerCase(); var exact = !value || repos.some(function (r) { return r.full_name.toLowerCase() === value; }); hint.hidden = exact; input.classList.toggle('repo-unknown', !exact); } /* ── Complétion Tab, segment par segment ─────────────── */ function commonPrefix(strings) { if (!strings.length) return ''; var prefix = strings[0]; for (var i = 1; i < strings.length; i++) { while (strings[i].indexOf(prefix) !== 0) { prefix = prefix.slice(0, -1); if (!prefix) return ''; } } return prefix; } function completeSegment() { if (!repos) return false; var value = input.value; var slash = value.lastIndexOf('/'); var head = slash >= 0 ? value.slice(0, slash + 1) : ''; var segment = slash >= 0 ? value.slice(slash + 1) : value; var pool = []; if (slash < 0) { var owners = {}; repos.forEach(function (r) { var o = r.full_name.split('/')[0]; if (o.toLowerCase().indexOf(segment.toLowerCase()) === 0) owners[o] = true; }); pool = Object.keys(owners); } else { var ownerPart = value.slice(0, slash).toLowerCase(); repos.forEach(function (r) { var parts = r.full_name.split('/'); if (parts[0].toLowerCase() === ownerPart && parts[1].toLowerCase().indexOf(segment.toLowerCase()) === 0) { pool.push(parts[1]); } }); } if (!pool.length) return false; var lcp = commonPrefix(pool); if (lcp.length > segment.length && lcp.toLowerCase().indexOf(segment.toLowerCase()) === 0) { var completed = head + lcp; // La complétion tombe sur un nom d'organisation exact : // poser le séparateur dans le même Tab (comme un shell // qui complète un répertoire) — le geste « pr » Tab // donne « Projets/ », prêt pour le segment du dépôt. if (slash < 0 && pool.indexOf(lcp) !== -1) completed += '/'; input.value = completed; refresh(true); return true; } // Segment d'organisation déjà complet et sans ambiguïté : // poser le séparateur et passer au segment du dépôt. if (slash < 0 && pool.length === 1 && lcp.toLowerCase() === segment.toLowerCase()) { input.value = head + lcp + '/'; refresh(true); return true; } return false; } /* ── Événements ──────────────────────────────────────── */ input.addEventListener('focus', function () { load().then(function () { refresh(true); }); }); input.addEventListener('input', function () { activeIndex = -1; load().then(function () { refresh(true); }); }); input.addEventListener('blur', function () { close(); validate(); }); input.addEventListener('keydown', function (e) { if (e.key === 'Tab') { if (completeSegment()) { e.preventDefault(); } else { close(); } return; } if (e.key === 'Escape') { close(); return; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); load().then(function () { if (!isOpen) { refresh(true); activeIndex = 0; highlight(); return; } var delta = e.key === 'ArrowDown' ? 1 : -1; activeIndex = Math.max(0, Math.min(candidates.length - 1, activeIndex + delta)); highlight(); }); return; } if (e.key === 'Enter') { if (isOpen && activeIndex >= 0 && candidates[activeIndex]) { e.preventDefault(); choose(candidates[activeIndex]); } else if (isOpen && candidates.length === 1) { e.preventDefault(); choose(candidates[0]); } } }); // Si le champ est pré-rempli (lien ?repo= ou défaut), valider // dès que la liste est connue. if (input.value) load().then(validate); })();