feat(capture): sélecteur de dépôt intelligent — suggestions, Tab par segment, validation

- 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.
This commit is contained in:
Spark
2026-10-12 15:47:28 -04:00
parent f5d2d7d716
commit 760fbd8c6d
4 changed files with 309 additions and 1 deletions
+29
View File
@@ -3513,6 +3513,35 @@ async def api_stats():
return get_stats()
@app.get("/api/repos")
async def api_repos():
"""Every repo, for pickers (capture page repo selector, 0.21.0).
Unlike /api/palette (capped at 60 for a launcher), a picker
must be exhaustive — Bruno has 130+ repos. Ranked for the
empty-query case: starred first, archived last, most
recently updated first in between.
"""
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
try:
starred = {r["full_name"] for r in await gitea.list_starred()}
except Exception:
starred = set()
out = [
{
"full_name": r["full_name"],
"starred": r["full_name"] in starred,
"updated_at": r.get("updated_at") or "",
"archived": bool(r.get("archived")),
}
for r in active + inactive + archived
]
out.sort(key=lambda x: x["updated_at"], reverse=True)
out.sort(key=lambda x: (not x["starred"], x["archived"]))
return {"repos": out}
@app.get("/api/palette")
async def api_palette(q: str = ""):
"""Data for the Ctrl+K command palette (roadmap #10).
+7 -1
View File
@@ -37,7 +37,12 @@
<form method="post" action="/capture" class="capture-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Dépôt
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
<span class="repo-picker">
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
</span>
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
</label>
<label>Titre de l'issue
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
@@ -84,6 +89,7 @@
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
</footer>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/repo-picker.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
+28
View File
@@ -1513,3 +1513,31 @@ a.ms-title:hover { text-decoration: underline; }
border: 1px solid var(--border, #333);
}
.health-back { font-size: 13.5px; }
/* ── Sélecteur de dépôt Capture (0.21.0) ────── */
.repo-picker { position: relative; display: block; }
.repo-list {
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 80;
display: flex; flex-direction: column;
background: var(--surface-1, #141a26);
border: 1px solid var(--border);
border-radius: 12px; padding: 5px;
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.5);
max-height: 264px; overflow-y: auto;
}
.repo-list[hidden] { display: none !important; }
.repo-item {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 10px; border-radius: 8px; cursor: pointer;
font-weight: 400; font-size: 0.94rem;
}
.repo-item.active { background: var(--surface-2, #1c2330); }
.repo-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.repo-item-meta { color: var(--text-3, #8b94a5); font-size: 0.78rem; white-space: nowrap; }
.repo-hint { font-weight: 400; font-size: 0.85rem; color: #f0a35e; }
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b94a5); }
.repo-tip kbd {
font-size: 0.72rem; border: 1px solid var(--border); border-bottom-width: 2px;
border-radius: 5px; padding: 0 5px; background: var(--surface);
}
.capture-form input.repo-unknown { border-color: #f0a35e; }
+245
View File
@@ -0,0 +1,245 @@
/**
* 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);
})();