- GET /api/repos exhaustif (épinglés, récents, archivés), la palette garde son plafond de 60. - repo-picker.js : liste filtrée sur les deux segments, complétion Tab façon shell (pr Tab → Projets/ avec le séparateur posé, kan Tab → kanbante), ↑/↓/Entrée/clic, avertissement doux si le dépôt n'existe pas.
246 lines
9.4 KiB
JavaScript
246 lines
9.4 KiB
JavaScript
/**
|
|
* 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);
|
|
})();
|