gitea brauchbar

This commit is contained in:
th committed 2026-04-10 15:44:51 +02:00
1 parent dd1afbbeb2
commit 7219bae655
6 files changed
+606 -60

No files matched your search

+388 -42
View File
@@ -488,6 +488,9 @@ function renderDetail() {
link.style.display = 'none';
}
// Metadata row (assignees / milestone / due date)
renderMetaRow();
// Comments — delegated to its own function so we can re-render after posting
renderCommentsSection();
@@ -498,17 +501,184 @@ function renderDetail() {
cancelTitleEdit();
cancelBodyEdit();
closeLabelsEditor();
closeMetaEditor();
setExtractStatus('');
$('gitea-comment-input').value = '';
$('gitea-comment-status').textContent = '';
}
// ── Metadata row + editor (assignees, milestone, due date) ──────
function renderMetaRow() {
if (!currentIssue) return;
const i = currentIssue;
const parts = [];
// Assignees
if (i.assignees && i.assignees.length) {
parts.push(`<span><i class="ph ph-users text-[10px]"></i> ${i.assignees.map(escapeHtml).join(', ')}</span>`);
} else {
parts.push(`<span class="text-t-muted"><i class="ph ph-users text-[10px]"></i> unassigned</span>`);
}
// Milestone
if (i.milestone && i.milestone.title) {
parts.push(`<span><i class="ph ph-flag text-[10px]"></i> ${escapeHtml(i.milestone.title)}</span>`);
} else {
parts.push(`<span class="text-t-muted"><i class="ph ph-flag text-[10px]"></i> no milestone</span>`);
}
// Due date
if (i.due_date) {
const ds = i.due_date.substring(0, 10);
parts.push(`<span><i class="ph ph-calendar text-[10px]"></i> ${escapeHtml(ds)}</span>`);
} else {
parts.push(`<span class="text-t-muted"><i class="ph ph-calendar text-[10px]"></i> no due date</span>`);
}
$('gitea-detail-meta').innerHTML = parts.join('');
}
let metaSelectedAssignees = new Set();
async function openMetaEditor() {
if (!currentIssue) return;
const editor = $('gitea-meta-editor');
editor.classList.remove('hidden');
closeLabelsEditor(); // don't show both at once
const i = currentIssue;
metaSelectedAssignees = new Set(i.assignees || []);
// Pre-fill due date input
$('gitea-meta-due').value = i.due_date ? i.due_date.substring(0, 10) : '';
// Loading placeholders
$('gitea-meta-assignees').innerHTML = '<div class="text-[10px] text-t-dim italic">loading...</div>';
$('gitea-meta-milestone').innerHTML = '<option value="">loading...</option>';
const ownerEnc = encodeURIComponent(i.owner);
const repoEnc = encodeURIComponent(i.repo_name);
const base = `api/gitea/repo/${currentAccountId}/${ownerEnc}/${repoEnc}`;
const [collabsRes, milestonesRes] = await Promise.allSettled([
fetch(`${base}/collaborators`).then(r => r.json()),
fetch(`${base}/milestones`).then(r => r.json()),
]);
// ── Collaborators (assignees) ──
const assigneesEl = $('gitea-meta-assignees');
if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) {
const collabs = collabsRes.value.collaborators || [];
if (!collabs.length) {
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">no collaborators</div>';
} else {
// Make sure currently-assigned users are present even if missing
// from the collaborator list (e.g. they were assigned before being
// removed as a collaborator)
const knownLogins = new Set(collabs.map(u => u.login));
const extras = (i.assignees || []).filter(login => !knownLogins.has(login)).map(login => ({ login, full_name: '' }));
const all = [...collabs, ...extras];
assigneesEl.innerHTML = all.map(u => `
<label class="gitea-label-checkbox" data-login="${escapeHtml(u.login)}">
<input type="checkbox" ${metaSelectedAssignees.has(u.login) ? 'checked' : ''}>
<i class="ph ph-user text-sm text-t-dim"></i>
<span class="text-[11px] text-t-text">${escapeHtml(u.login)}</span>
${u.full_name ? `<span class="text-[9px] text-t-dim ml-1">${escapeHtml(u.full_name)}</span>` : ''}
</label>
`).join('');
assigneesEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
cb.addEventListener('change', () => {
const login = cb.closest('.gitea-label-checkbox').dataset.login;
if (cb.checked) metaSelectedAssignees.add(login);
else metaSelectedAssignees.delete(login);
});
});
}
} else {
const err = collabsRes.status === 'fulfilled' ? collabsRes.value.error : collabsRes.reason?.message;
assigneesEl.innerHTML = `<div class="text-[10px] text-t-dim italic break-words">cannot list collaborators: ${escapeHtml(err || 'failed')}</div>`;
}
// ── Milestones ──
const milestoneSel = $('gitea-meta-milestone');
if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) {
const milestones = milestonesRes.value.milestones || [];
const currentId = i.milestone?.id;
let html = '<option value="">— no milestone —</option>';
if (!milestones.length && !currentId) {
html = '<option value="">— no open milestones —</option>';
}
// If current milestone exists but isn't in the open list (e.g. closed),
// include it explicitly so we don't lose the selection
if (currentId && !milestones.find(m => m.id === currentId)) {
html += `<option value="${currentId}" selected>${escapeHtml(i.milestone.title)} (closed)</option>`;
}
html += milestones.map(ms =>
`<option value="${ms.id}" ${ms.id === currentId ? 'selected' : ''}>${escapeHtml(ms.title)}${ms.due_on ? ' · due ' + escapeHtml(ms.due_on.substring(0, 10)) : ''}</option>`
).join('');
milestoneSel.innerHTML = html;
} else {
milestoneSel.innerHTML = '<option value="">— could not load —</option>';
}
}
function closeMetaEditor() {
$('gitea-meta-editor').classList.add('hidden');
}
function clearMetaDue() {
$('gitea-meta-due').value = '';
}
async function saveMetaEditor() {
if (!currentIssue) return;
setExtractStatus('saving metadata...');
const payload = {};
payload.assignees = [...metaSelectedAssignees];
const milestoneVal = $('gitea-meta-milestone').value;
payload.milestone = milestoneVal ? parseInt(milestoneVal) : 0; // 0 = clear in gitea
const dueVal = $('gitea-meta-due').value;
if (dueVal) {
// Gitea expects RFC3339; append T00:00:00Z to make it valid
payload.due_date = dueVal + 'T00:00:00Z';
} else {
payload.unset_due_date = true;
}
try {
const res = await patchIssue(payload);
if (!res.ok) {
setExtractStatus('error: ' + (res.error || 'failed'), 'error');
return;
}
// Refresh local state from the latest detail call (PATCH only returns
// a subset, so re-fetch for accuracy)
const detailRes = await fetch(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/${currentIssue.number}`);
const detailData = await detailRes.json();
if (detailData.ok) {
currentIssue = { ...detailData.issue, comments: currentIssue.comments };
renderMetaRow();
}
closeMetaEditor();
setExtractStatus('metadata updated', 'success');
updateListRow();
} catch (err) {
setExtractStatus('error: ' + err.message, 'error');
}
}
// ── Create issue ──────────────────────────────────────────────────
let cachedRepos = null; // per-account cache
let cachedReposForAccount = null;
let cachedReposError = null; // last error message for repo fetch (so it shows on reopen)
let createSelectedLabels = new Set();
let createSelectedAssignees = new Set();
let createLastLoadedRepo = null; // dedupe: don't reload metadata if value hasn't changed
function setCreateStatus(text, kind = 'info') {
const el = $('gitea-create-status');
@@ -549,27 +719,29 @@ async function openCreateForm() {
$('gitea-create-body').value = '';
setCreateStatus('');
createSelectedLabels.clear();
createSelectedAssignees.clear();
createLastLoadedRepo = null;
$('gitea-create-labels').innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
$('gitea-create-assignees').innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
$('gitea-create-milestone').innerHTML = '<option value="">— no milestone —</option>';
// Try to populate the datalist autocomplete with the user's repos.
// Try to load the user's repos for the custom autocomplete dropdown.
// If the token lacks read:repository scope this will 403 — the input is
// still usable manually, but we surface the error so the user knows why
// autocomplete isn't working and can update the token if they want.
const datalist = $('gitea-create-repo-list');
const hint = $('gitea-create-repo-hint');
const errBox = $('gitea-create-repo-error');
errBox.classList.add('hidden');
errBox.textContent = '';
hideRepoDropdown();
if (cachedReposForAccount === currentAccountId && cachedRepos) {
renderRepoDatalist(cachedRepos);
// If the cached fetch had failed, also restore the error message
updateRepoHint();
if (cachedReposError) {
errBox.textContent = cachedReposError;
errBox.classList.remove('hidden');
}
} else {
datalist.innerHTML = '';
hint.textContent = '(loading suggestions...)';
try {
const res = await fetch(`api/gitea/repos?account=${currentAccountId}`);
@@ -586,7 +758,7 @@ async function openCreateForm() {
cachedRepos = data.repos || [];
cachedReposForAccount = currentAccountId;
cachedReposError = null;
renderRepoDatalist(cachedRepos);
updateRepoHint();
} catch (err) {
hint.textContent = '(type owner/repo manually)';
cachedReposError = 'Network error loading repos: ' + err.message;
@@ -596,27 +768,108 @@ async function openCreateForm() {
}
}
function renderRepoDatalist(repos) {
const datalist = $('gitea-create-repo-list');
function updateRepoHint() {
const hint = $('gitea-create-repo-hint');
if (!repos.length) {
datalist.innerHTML = '';
hint.textContent = '(type owner/repo manually)';
const n = (cachedRepos || []).length;
hint.textContent = n > 0 ? `(${n} suggestion${n === 1 ? '' : 's'})` : '(type owner/repo manually)';
}
// ── Custom repo autocomplete dropdown ─────────────────────────────
let repoDropdownActive = -1;
function showRepoDropdown() {
const dropdown = $('gitea-create-repo-dropdown');
const input = $('gitea-create-repo');
if (!dropdown || !input) return;
if (!cachedRepos || !cachedRepos.length) return;
const query = input.value.trim().toLowerCase();
const matches = query
? cachedRepos.filter(r => (r.full_name || '').toLowerCase().includes(query))
: cachedRepos.slice();
if (!matches.length) {
hideRepoDropdown();
return;
}
datalist.innerHTML = repos.map(r =>
`<option value="${escapeHtml(r.full_name)}">${r.private ? ' (private)' : ''}</option>`
).join('');
hint.textContent = `(${repos.length} suggestion${repos.length === 1 ? '' : 's'})`;
// Cap to first 50 to keep DOM cheap
const display = matches.slice(0, 50);
dropdown.innerHTML = display.map((r, idx) => `
<div class="gitea-repo-option" data-value="${escapeHtml(r.full_name)}" data-idx="${idx}">
<i class="ph ph-folder text-sm text-t-dim"></i>
<span>${escapeHtml(r.full_name)}</span>
${r.private ? '<span class="gitea-repo-tag">private</span>' : ''}
</div>
`).join('');
dropdown.classList.remove('hidden');
repoDropdownActive = -1;
dropdown.querySelectorAll('.gitea-repo-option').forEach(opt => {
// Use mousedown so the click registers BEFORE input blur fires
opt.addEventListener('mousedown', e => {
e.preventDefault();
input.value = opt.dataset.value;
hideRepoDropdown();
loadCreateRepoLabels();
});
});
}
function hideRepoDropdown() {
const dropdown = $('gitea-create-repo-dropdown');
if (!dropdown) return;
dropdown.classList.add('hidden');
dropdown.innerHTML = '';
repoDropdownActive = -1;
}
function moveRepoDropdownActive(delta) {
const dropdown = $('gitea-create-repo-dropdown');
if (!dropdown || dropdown.classList.contains('hidden')) return;
const opts = dropdown.querySelectorAll('.gitea-repo-option');
if (!opts.length) return;
repoDropdownActive = (repoDropdownActive + delta + opts.length) % opts.length;
opts.forEach((o, i) => o.classList.toggle('active', i === repoDropdownActive));
opts[repoDropdownActive].scrollIntoView({ block: 'nearest' });
}
function commitRepoDropdownActive() {
const dropdown = $('gitea-create-repo-dropdown');
if (!dropdown || dropdown.classList.contains('hidden')) return false;
const opts = dropdown.querySelectorAll('.gitea-repo-option');
if (repoDropdownActive < 0 || repoDropdownActive >= opts.length) return false;
const opt = opts[repoDropdownActive];
$('gitea-create-repo').value = opt.dataset.value;
hideRepoDropdown();
loadCreateRepoLabels();
return true;
}
async function loadCreateRepoLabels() {
// Loads labels + milestones + collaborators in parallel for the picked
// repo. Deduped: if the same repo value is passed again (e.g. blur fires
// change after a dropdown click already triggered a load), we skip —
// otherwise we'd wipe the user's already-checked labels/assignees.
const input = $('gitea-create-repo');
const fullName = input.value.trim();
if (fullName === createLastLoadedRepo) return; // already loaded → don't reset
const labelsEl = $('gitea-create-labels');
const assigneesEl = $('gitea-create-assignees');
const milestoneSel = $('gitea-create-milestone');
// Now we know we're loading a different repo — safe to clear selections
createSelectedLabels.clear();
createSelectedAssignees.clear();
createLastLoadedRepo = fullName;
if (!fullName || !fullName.includes('/')) {
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first (owner/repo)</div>';
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
milestoneSel.innerHTML = '<option value="">— no milestone —</option>';
return;
}
const [owner, repo] = fullName.split('/');
@@ -624,34 +877,95 @@ async function loadCreateRepoLabels() {
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">format: owner/repo</div>';
return;
}
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">loading labels...</div>';
try {
const res = await fetch(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`);
const data = await res.json();
if (!data.ok) {
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(data.error || 'failed')}</div>`;
return;
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">loading...</div>';
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">loading...</div>';
milestoneSel.innerHTML = '<option value="">loading...</option>';
const ownerEnc = encodeURIComponent(owner);
const repoEnc = encodeURIComponent(repo);
const base = `api/gitea/repo/${currentAccountId}/${ownerEnc}/${repoEnc}`;
// Fetch all three in parallel — each one may independently fail (e.g.
// collaborators 403 if the token can't see them)
const [labelsRes, milestonesRes, collabsRes] = await Promise.allSettled([
fetch(`${base}/labels`).then(r => r.json()),
fetch(`${base}/milestones`).then(r => r.json()),
fetch(`${base}/collaborators`).then(r => r.json()),
]);
// ── Labels ──
const warningEl = $('gitea-create-labels-warning');
warningEl.classList.add('hidden');
warningEl.textContent = '';
if (labelsRes.status === 'fulfilled' && labelsRes.value.ok) {
const labels = labelsRes.value.labels || [];
// Surface backend warning (e.g. missing read:organization scope)
if (labelsRes.value.warning) {
warningEl.textContent = '⚠ ' + labelsRes.value.warning;
warningEl.classList.remove('hidden');
}
const labels = data.labels || [];
if (!labels.length) {
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">repo has no labels</div>';
return;
}
labelsEl.innerHTML = labels.map(l => `
<label class="gitea-label-checkbox" data-id="${l.id}">
<input type="checkbox">
<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>
</label>
`).join('');
labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
cb.addEventListener('change', () => {
const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
if (cb.checked) createSelectedLabels.add(id);
else createSelectedLabels.delete(id);
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">no labels</div>';
} else {
labelsEl.innerHTML = labels.map(l => `
<label class="gitea-label-checkbox" data-id="${l.id}">
<input type="checkbox">
<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>
</label>
`).join('');
labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
cb.addEventListener('change', () => {
const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
if (cb.checked) createSelectedLabels.add(id);
else createSelectedLabels.delete(id);
});
});
});
} catch (err) {
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger">${escapeHtml(err.message)}</div>`;
}
} else {
const err = labelsRes.status === 'fulfilled' ? labelsRes.value.error : labelsRes.reason?.message;
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(err || 'failed')}</div>`;
}
// ── Milestones ──
if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) {
const milestones = milestonesRes.value.milestones || [];
if (!milestones.length) {
milestoneSel.innerHTML = '<option value="">— no milestones available —</option>';
} else {
milestoneSel.innerHTML = '<option value="">— no milestone —</option>' + milestones.map(ms =>
`<option value="${ms.id}">${escapeHtml(ms.title)}${ms.due_on ? ' · due ' + escapeHtml(ms.due_on.substring(0, 10)) : ''}</option>`
).join('');
}
} else {
milestoneSel.innerHTML = '<option value="">— could not load —</option>';
}
// ── Collaborators (assignees) ──
if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) {
const collabs = collabsRes.value.collaborators || [];
if (!collabs.length) {
assigneesEl.innerHTML = '<div class="text-[10px] text-t-dim italic">no collaborators</div>';
} else {
assigneesEl.innerHTML = collabs.map(u => `
<label class="gitea-label-checkbox" data-login="${escapeHtml(u.login)}">
<input type="checkbox">
<i class="ph ph-user text-sm text-t-dim"></i>
<span class="text-[11px] text-t-text">${escapeHtml(u.login)}</span>
${u.full_name ? `<span class="text-[9px] text-t-dim ml-1">${escapeHtml(u.full_name)}</span>` : ''}
</label>
`).join('');
assigneesEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
cb.addEventListener('change', () => {
const login = cb.closest('.gitea-label-checkbox').dataset.login;
if (cb.checked) createSelectedAssignees.add(login);
else createSelectedAssignees.delete(login);
});
});
}
} else {
const err = collabsRes.status === 'fulfilled' ? collabsRes.value.error : collabsRes.reason?.message;
assigneesEl.innerHTML = `<div class="text-[10px] text-t-dim italic break-words">cannot list collaborators: ${escapeHtml(err || 'failed')}</div>`;
}
}
@@ -683,6 +997,9 @@ async function submitCreateIssue() {
try {
const payload = { title, body };
if (createSelectedLabels.size > 0) payload.labels = [...createSelectedLabels];
if (createSelectedAssignees.size > 0) payload.assignees = [...createSelectedAssignees];
const milestoneId = parseInt($('gitea-create-milestone').value);
if (milestoneId) payload.milestone = milestoneId;
const res = await hyprcrd.api(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, {
method: 'POST',
body: payload,
@@ -867,6 +1184,9 @@ async function openLabelsEditor() {
const editor = $('gitea-labels-editor');
editor.classList.remove('hidden');
const list = $('gitea-labels-checkboxes');
const warning = $('gitea-labels-warning');
warning.classList.add('hidden');
warning.textContent = '';
list.innerHTML = '<div class="text-[10px] text-t-dim italic">loading labels...</div>';
try {
@@ -876,6 +1196,10 @@ async function openLabelsEditor() {
list.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(data.error || 'failed to load')}</div>`;
return;
}
if (data.warning) {
warning.textContent = '⚠ ' + data.warning;
warning.classList.remove('hidden');
}
availableRepoLabels = data.labels || [];
// Match current selections by name (since the issue's labels list contains
// {name, color} not ids)
@@ -1193,8 +1517,24 @@ function init() {
$('gitea-create-cancel').addEventListener('click', closeCreateForm);
$('gitea-create-cancel-2').addEventListener('click', closeCreateForm);
$('gitea-create-submit').addEventListener('click', submitCreateIssue);
// Auto-load labels after the user picks/types a repo (change fires on blur)
$('gitea-create-repo').addEventListener('change', loadCreateRepoLabels);
// Repo input — custom autocomplete dropdown
const repoInput = $('gitea-create-repo');
repoInput.addEventListener('focus', showRepoDropdown);
repoInput.addEventListener('input', showRepoDropdown);
repoInput.addEventListener('blur', () => {
// Delay hide so option mousedown can register
setTimeout(hideRepoDropdown, 150);
});
repoInput.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); moveRepoDropdownActive(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveRepoDropdownActive(-1); }
else if (e.key === 'Enter') {
if (commitRepoDropdownActive()) { e.preventDefault(); }
}
else if (e.key === 'Escape') { hideRepoDropdown(); }
});
// Auto-load labels when the user leaves the field (no dropdown commit)
repoInput.addEventListener('change', loadCreateRepoLabels);
// Detail buttons
$('gitea-extract-btn').addEventListener('click', extractIssue);
@@ -1223,6 +1563,12 @@ function init() {
$('gitea-labels-save').addEventListener('click', saveLabels);
$('gitea-labels-cancel').addEventListener('click', closeLabelsEditor);
// Metadata editor (assignees + milestone + due date)
$('gitea-meta-edit-btn').addEventListener('click', openMetaEditor);
$('gitea-meta-save').addEventListener('click', saveMetaEditor);
$('gitea-meta-cancel').addEventListener('click', closeMetaEditor);
$('gitea-meta-due-clear').addEventListener('click', clearMetaDue);
// Comment composer
$('gitea-comment-post').addEventListener('click', postComment);
$('gitea-comment-input').addEventListener('keydown', e => {