// ── Gitea Plugin Frontend (scaffold) ── // // Phase 1 (this file): account management + connection test. // Phase 2: issue list, detail view, extract-as-card, comments. // // Self-registers via window.hyprcrd: // - Header button "gitea" // - Settings tab "gitea" with account CRUD // - Welcome view in the overlay until at least one account exists const hyprcrd = window.hyprcrd; if (!hyprcrd) throw new Error('[gitea plugin] window.hyprcrd missing'); const $ = (id) => document.getElementById(id); // ── State ───────────────────────────────────────────────────────── let accounts = []; let currentAccountId = null; let editingAccountId = null; let currentIssues = []; let currentIssue = null; // detail data let currentPage = 1; let currentHasMore = false; let currentState = 'open'; let currentQuery = ''; let currentScope = 'all'; // all | assigned | created | mentioned const PAGE_SIZE = 30; // ── Overlay open / close ────────────────────────────────────────── async function openGitea() { $('gitea-overlay').classList.add('open'); await loadAccounts(); if (accounts.length === 0) { showWelcomeView(); } else { if (!accounts.find(a => a.id === currentAccountId)) { currentAccountId = accounts[0].id; } $('gitea-account-select').value = currentAccountId; showIssuesView(); currentPage = 1; loadIssues(); } } function closeGitea() { $('gitea-overlay').classList.remove('open'); } function showWelcomeView() { $('gitea-view-welcome').classList.remove('hidden'); $('gitea-view-issues').classList.add('hidden'); $('gitea-view-issues').classList.remove('flex'); $('gitea-status').textContent = ''; } function showIssuesView() { $('gitea-view-welcome').classList.add('hidden'); $('gitea-view-issues').classList.remove('hidden'); $('gitea-view-issues').classList.add('flex'); } function gotoAccountsSettings() { closeGitea(); hyprcrd.settings.openTab('gitea'); } // ── Accounts: data ──────────────────────────────────────────────── async function loadAccounts() { const res = await fetch('api/gitea/accounts'); accounts = await res.json(); if (!Array.isArray(accounts)) accounts = []; renderAccountSelect(); } function renderAccountSelect() { const sel = $('gitea-account-select'); if (!sel) return; sel.innerHTML = accounts.length ? accounts.map(a => ``).join('') : ''; } // ── Settings tab: rendering ─────────────────────────────────────── function buildSettingsPanel(panelEl) { panelEl.innerHTML = `
gitea accounts

Configure Gitea instances to read issues inside hyprcrd. Tokens are encrypted with the vault key. Create a personal access token in your Gitea profile under Settings → Applications.

add account
`; panelEl.querySelector('#gitea-account-form').addEventListener('submit', saveAccount); panelEl.querySelector('#gitea-acc-cancel').addEventListener('click', resetAccountForm); panelEl.querySelector('#gitea-acc-test').addEventListener('click', testCurrentForm); } async function refreshSettingsPanel(panelEl) { if (!panelEl.querySelector('#gitea-account-form')) { buildSettingsPanel(panelEl); } await loadAccounts(); renderAccountsList(); resetAccountForm(); } function renderAccountsList() { const list = $('gitea-accounts-list'); if (!list) return; if (!accounts.length) { list.innerHTML = '

No accounts configured yet. Use the form below to add one.

'; return; } list.innerHTML = accounts.map(a => `
`).join(''); list.querySelectorAll('.gitea-account-row').forEach(row => { const id = parseInt(row.dataset.id); row.querySelector('.gitea-edit-btn').addEventListener('click', () => editAccount(id)); row.querySelector('.gitea-del-btn').addEventListener('click', () => deleteAccount(id)); row.querySelector('.gitea-test-btn').addEventListener('click', () => testExistingAccount(id)); }); } function setAccountStatus(text, kind = 'info') { const el = $('gitea-acc-status'); if (!el) return; if (!text) { el.classList.add('hidden'); el.textContent = ''; el.style.borderColor = ''; el.style.color = ''; return; } el.classList.remove('hidden'); el.textContent = text; if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; } else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; } else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; } } function resetAccountForm() { editingAccountId = null; const form = $('gitea-account-form'); if (!form) return; $('gitea-form-title').textContent = 'add account'; $('gitea-acc-id').value = ''; $('gitea-acc-name').value = ''; $('gitea-acc-baseurl').value = ''; $('gitea-acc-token').value = ''; setAccountStatus(''); $('gitea-tok-hint').textContent = ''; } function editAccount(id) { const acc = accounts.find(a => a.id === id); if (!acc) return; editingAccountId = id; $('gitea-form-title').textContent = 'edit account: ' + acc.name; $('gitea-acc-id').value = id; $('gitea-acc-name').value = acc.name; $('gitea-acc-baseurl').value = acc.base_url; $('gitea-acc-token').value = ''; $('gitea-tok-hint').textContent = '(leave blank to keep existing)'; setAccountStatus(''); $('gitea-acc-name').focus(); } async function deleteAccount(id) { const acc = accounts.find(a => a.id === id); if (!acc) return; const ok = await hyprcrd.dialog.confirm( `Delete gitea account "${acc.name}"? Stored token will be removed but no issues are deleted on the server.`, { title: 'delete account', okLabel: 'Delete', cancelLabel: 'Cancel', danger: true } ); if (!ok) return; await hyprcrd.api(`api/gitea/accounts/${id}`, { method: 'DELETE' }); await loadAccounts(); renderAccountsList(); } async function saveAccount(e) { e.preventDefault(); setAccountStatus('saving...'); const payload = { name: $('gitea-acc-name').value.trim(), base_url: $('gitea-acc-baseurl').value.trim(), token: $('gitea-acc-token').value, }; if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) { setAccountStatus('name, base url, and token are required', 'error'); return; } if (editingAccountId && !payload.token) delete payload.token; try { let res; if (editingAccountId) { res = await hyprcrd.api(`api/gitea/accounts/${editingAccountId}`, { method: 'PATCH', body: payload }); } else { res = await hyprcrd.api('api/gitea/accounts', { method: 'POST', body: payload }); } if (res.error) { setAccountStatus('error: ' + res.error, 'error'); return; } setAccountStatus('saved', 'success'); await loadAccounts(); renderAccountsList(); // Don't fully reset — keep saved state visible briefly setTimeout(() => { resetAccountForm(); }, 1500); } catch (err) { setAccountStatus('error: ' + err.message, 'error'); } } async function testExistingAccount(id) { setAccountStatus('testing...'); try { const res = await hyprcrd.api(`api/gitea/accounts/${id}/test`, { method: 'POST' }); if (res.ok) { const u = res.user || {}; const who = u.login || u.full_name || 'unknown'; const extras = []; if (u.full_name && u.full_name !== u.login) extras.push(u.full_name); if (u.email) extras.push(u.email); const detail = extras.length ? ` (${extras.join(', ')})` : ''; setAccountStatus(`ok — connected as ${who}${detail}`, 'success'); } else { setAccountStatus('failed: ' + (res.error || 'unknown'), 'error'); } } catch (err) { setAccountStatus('error: ' + err.message, 'error'); } } async function testCurrentForm() { if (!editingAccountId) { setAccountStatus('save first, then test'); return; } await testExistingAccount(editingAccountId); } // ── Issues: load + render ───────────────────────────────────────── async function loadIssues() { if (!currentAccountId) return; const list = $('gitea-issue-list'); list.innerHTML = '
'; $('gitea-status').textContent = 'fetching...'; const params = new URLSearchParams({ account: currentAccountId, state: currentState, scope: currentScope, page: currentPage, limit: PAGE_SIZE, }); if (currentQuery) params.set('q', currentQuery); try { const res = await fetch('api/gitea/issues?' + params.toString()); const data = await res.json(); if (!data.ok) { list.innerHTML = `
${escapeHtml(data.error || 'failed to load')}
`; $('gitea-status').textContent = 'error'; renderPagination(); return; } currentIssues = data.issues || []; currentHasMore = !!data.hasMore; renderIssueList(); renderPagination(); $('gitea-status').textContent = `${currentIssues.length} issue${currentIssues.length === 1 ? '' : 's'}`; } catch (err) { list.innerHTML = `
${escapeHtml(err.message)}
`; $('gitea-status').textContent = 'error'; } } function renderIssueList() { const list = $('gitea-issue-list'); if (!currentIssues.length) { list.innerHTML = '
no issues
'; return; } list.innerHTML = currentIssues.map(issue => { const [owner, repoName] = (issue.repo || '/').split('/'); return `
${escapeHtml(issue.repo || '')}
${escapeHtml(issue.title || '(no title)')}
${issue.labels.length ? `
${issue.labels.map(l => `${escapeHtml(l.name)}`).join('')}
` : ''}
${issue.state} #${issue.number} ${escapeHtml(issue.author || '')} ${issue.comments_count > 0 ? ` ${issue.comments_count}` : ''} ${escapeHtml(formatRelative(issue.updated_at || issue.created_at))}
`;}).join(''); list.querySelectorAll('.gitea-issue-row').forEach(row => { row.addEventListener('click', () => { openIssueDetail(row.dataset.owner, row.dataset.repo, parseInt(row.dataset.number), row); }); }); } function renderPagination() { $('gitea-page-info').textContent = `page ${currentPage}`; const prev = $('gitea-page-prev'); const next = $('gitea-page-next'); prev.disabled = currentPage <= 1; next.disabled = !currentHasMore; prev.classList.toggle('opacity-30', prev.disabled); prev.classList.toggle('cursor-not-allowed', prev.disabled); next.classList.toggle('opacity-30', next.disabled); next.classList.toggle('cursor-not-allowed', next.disabled); } // Decide black/white text on a label background based on luminance function labelTextColor(hex) { if (!hex || hex.length < 6) return '#000'; const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum > 0.55 ? '#000' : '#fff'; } // ── Attachment URL rewriter ─────────────────────────────────────── // // Gitea embeds screenshots as or // ![alt](/attachments/) in markdown. The browser can't fetch // those directly (CORS + no auth), so rewrite them to the hyprcrd // proxy which forwards with the account's token. function rewriteAttachmentPaths(text, accountId) { if (!text) return text; const acc = accounts.find(a => a.id === accountId); const baseUrl = acc?.base_url || ''; let result = text; // 1. Rewrite absolute URLs against the configured base_url if (baseUrl) { const baseEsc = baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const absRe = new RegExp(`${baseEsc}(/attachments/[^"'\\s)<>]+)`, 'gi'); result = result.replace(absRe, (_m, path) => `api/gitea/attachment/${accountId}${path}`); } // 2. Rewrite relative /attachments/... paths. // The capture group on the prefix preserves the surrounding char // (quote, paren, whitespace) so we don't accidentally match // /something/attachments/... result = result.replace( /(["'(\s])(\/attachments\/[^"'\s)<>]+)/gi, (_m, prefix, path) => `${prefix}api/gitea/attachment/${accountId}${path}` ); return result; } // ── Issue detail ────────────────────────────────────────────────── async function openIssueDetail(owner, repo, number, rowEl) { document.querySelectorAll('.gitea-issue-row').forEach(r => r.classList.remove('active')); rowEl?.classList.add('active'); // Hide the create form if it was open $('gitea-create-form').classList.add('hidden'); $('gitea-create-form').classList.remove('flex'); $('gitea-detail').classList.add('hidden'); $('gitea-detail').classList.remove('flex'); $('gitea-detail-empty').classList.remove('hidden'); $('gitea-status').textContent = 'opening...'; try { const res = await fetch(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`); const data = await res.json(); if (!data.ok) { $('gitea-status').textContent = 'error: ' + (data.error || ''); return; } currentIssue = { ...data.issue, comments: data.comments || [] }; renderDetail(); $('gitea-detail-empty').classList.add('hidden'); $('gitea-detail').classList.remove('hidden'); $('gitea-detail').classList.add('flex'); $('gitea-status').textContent = ''; } catch (err) { $('gitea-status').textContent = 'error'; } } function renderDetail() { if (!currentIssue) return; const i = currentIssue; $('gitea-detail-title').textContent = i.title || '(no title)'; $('gitea-detail-number').textContent = '#' + i.number; $('gitea-detail-repo').textContent = i.repo || ''; $('gitea-detail-author').textContent = i.author || ''; $('gitea-detail-date').textContent = formatDateTime(i.created_at); const stateBadge = $('gitea-detail-state'); stateBadge.className = 'gitea-state-badge ' + i.state; stateBadge.textContent = i.state; // Labels const labelsEl = $('gitea-detail-labels'); labelsEl.innerHTML = (i.labels || []).map(l => `${escapeHtml(l.name)}` ).join(''); // Body — render markdown via core, after rewriting attachment paths const bodyEl = $('gitea-detail-body'); const bodyForRender = rewriteAttachmentPaths(i.body, currentAccountId); if (typeof hyprcrd.markdown.render === 'function' && bodyForRender) { bodyEl.innerHTML = hyprcrd.markdown.render(bodyForRender); } else { bodyEl.innerHTML = bodyForRender ? `
${escapeHtml(bodyForRender)}
` : '(no description)'; } // Open on Gitea link const link = $('gitea-open-link'); if (i.html_url) { link.href = i.html_url; link.style.display = ''; } else { 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(); // State toggle button label/icon renderStateToggleButton(); // Reset edit modes if any leftover 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(` ${i.assignees.map(escapeHtml).join(', ')}`); } else { parts.push(` unassigned`); } // Milestone if (i.milestone && i.milestone.title) { parts.push(` ${escapeHtml(i.milestone.title)}`); } else { parts.push(` no milestone`); } // Due date if (i.due_date) { const ds = i.due_date.substring(0, 10); parts.push(` ${escapeHtml(ds)}`); } else { parts.push(` no due date`); } $('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 = '
loading...
'; $('gitea-meta-milestone').innerHTML = ''; 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 = '
no collaborators
'; } 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 => ` `).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 = `
cannot list collaborators: ${escapeHtml(err || 'failed')}
`; } // ── 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 = ''; if (!milestones.length && !currentId) { html = ''; } // 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 += ``; } html += milestones.map(ms => `` ).join(''); milestoneSel.innerHTML = html; } else { milestoneSel.innerHTML = ''; } } 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'); if (!el) return; if (!text) { el.classList.add('hidden'); el.textContent = ''; el.style.borderColor = ''; el.style.color = ''; return; } el.classList.remove('hidden'); el.textContent = text; if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; } else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; } else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; } } async function openCreateForm() { if (!currentAccountId) return; // Hide other detail views $('gitea-detail').classList.add('hidden'); $('gitea-detail').classList.remove('flex'); $('gitea-detail-empty').classList.add('hidden'); $('gitea-create-form').classList.remove('hidden'); $('gitea-create-form').classList.add('flex'); // Reset form $('gitea-create-repo').value = ''; $('gitea-create-title').value = ''; $('gitea-create-body').value = ''; setCreateStatus(''); createSelectedLabels.clear(); createSelectedAssignees.clear(); createLastLoadedRepo = null; $('gitea-create-labels').innerHTML = '
pick a repo first
'; $('gitea-create-assignees').innerHTML = '
pick a repo first
'; $('gitea-create-milestone').innerHTML = ''; // 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 hint = $('gitea-create-repo-hint'); const errBox = $('gitea-create-repo-error'); errBox.classList.add('hidden'); errBox.textContent = ''; hideRepoDropdown(); if (cachedReposForAccount === currentAccountId && cachedRepos) { updateRepoHint(); if (cachedReposError) { errBox.textContent = cachedReposError; errBox.classList.remove('hidden'); } } else { hint.textContent = '(loading suggestions...)'; try { const res = await fetch(`api/gitea/repos?account=${currentAccountId}`); const data = await res.json(); if (!data.ok) { hint.textContent = '(type owner/repo manually — autocomplete failed)'; cachedRepos = []; cachedReposForAccount = currentAccountId; cachedReposError = 'Could not load repo suggestions: ' + (data.error || 'unknown'); errBox.textContent = cachedReposError; errBox.classList.remove('hidden'); return; } cachedRepos = data.repos || []; cachedReposForAccount = currentAccountId; cachedReposError = null; updateRepoHint(); } catch (err) { hint.textContent = '(type owner/repo manually)'; cachedReposError = 'Network error loading repos: ' + err.message; errBox.textContent = cachedReposError; errBox.classList.remove('hidden'); } } } function updateRepoHint() { const hint = $('gitea-create-repo-hint'); 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; } // Cap to first 50 to keep DOM cheap const display = matches.slice(0, 50); dropdown.innerHTML = display.map((r, idx) => `
${escapeHtml(r.full_name)} ${r.private ? 'private' : ''}
`).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 = '
pick a repo first (owner/repo)
'; assigneesEl.innerHTML = '
pick a repo first
'; milestoneSel.innerHTML = ''; return; } const [owner, repo] = fullName.split('/'); if (!owner || !repo) { labelsEl.innerHTML = '
format: owner/repo
'; return; } labelsEl.innerHTML = '
loading...
'; assigneesEl.innerHTML = '
loading...
'; milestoneSel.innerHTML = ''; 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'); } if (!labels.length) { labelsEl.innerHTML = '
no labels
'; } else { labelsEl.innerHTML = labels.map(l => ` `).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); }); }); } } else { const err = labelsRes.status === 'fulfilled' ? labelsRes.value.error : labelsRes.reason?.message; labelsEl.innerHTML = `
${escapeHtml(err || 'failed')}
`; } // ── Milestones ── if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) { const milestones = milestonesRes.value.milestones || []; if (!milestones.length) { milestoneSel.innerHTML = ''; } else { milestoneSel.innerHTML = '' + milestones.map(ms => `` ).join(''); } } else { milestoneSel.innerHTML = ''; } // ── Collaborators (assignees) ── if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) { const collabs = collabsRes.value.collaborators || []; if (!collabs.length) { assigneesEl.innerHTML = '
no collaborators
'; } else { assigneesEl.innerHTML = collabs.map(u => ` `).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 = `
cannot list collaborators: ${escapeHtml(err || 'failed')}
`; } } function closeCreateForm() { $('gitea-create-form').classList.add('hidden'); $('gitea-create-form').classList.remove('flex'); if (currentIssue) { $('gitea-detail').classList.remove('hidden'); $('gitea-detail').classList.add('flex'); } else { $('gitea-detail-empty').classList.remove('hidden'); } } async function submitCreateIssue() { const fullName = $('gitea-create-repo').value.trim(); const title = $('gitea-create-title').value.trim(); const body = $('gitea-create-body').value; if (!fullName || !fullName.includes('/')) { setCreateStatus('repo is required (format: owner/repo)', 'error'); return; } if (!title) { setCreateStatus('title is required', 'error'); return; } const [owner, repo] = fullName.split('/'); setCreateStatus('creating...'); 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, }); if (!res.ok) { setCreateStatus('error: ' + (res.error || 'failed'), 'error'); return; } setCreateStatus(`created #${res.issue.number}`, 'success'); // Refresh the list and open the new issue currentPage = 1; await loadIssues(); // Open it in detail view setTimeout(() => { const row = document.querySelector(`.gitea-issue-row[data-owner="${owner}"][data-repo="${repo}"][data-number="${res.issue.number}"]`); openIssueDetail(owner, repo, res.issue.number, row); }, 200); } catch (err) { setCreateStatus('error: ' + err.message, 'error'); } } // ── Status helper for the detail action area ────────────────────── function setExtractStatus(text, kind = 'info') { const el = $('gitea-extract-status'); if (!el) return; if (!text) { el.classList.add('hidden'); el.textContent = ''; el.style.borderColor = ''; el.style.color = ''; return; } el.classList.remove('hidden'); el.textContent = text; if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; } else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; } else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; } } // ── Title edit ──────────────────────────────────────────────────── function startTitleEdit() { if (!currentIssue) return; $('gitea-title-display').classList.add('hidden'); $('gitea-title-edit').classList.remove('hidden'); $('gitea-title-edit').classList.add('flex'); $('gitea-title-input').value = currentIssue.title || ''; $('gitea-title-input').focus(); $('gitea-title-input').select(); } function cancelTitleEdit() { $('gitea-title-display').classList.remove('hidden'); $('gitea-title-edit').classList.add('hidden'); $('gitea-title-edit').classList.remove('flex'); } async function saveTitleEdit() { if (!currentIssue) return; const newTitle = $('gitea-title-input').value.trim(); if (!newTitle || newTitle === currentIssue.title) { cancelTitleEdit(); return; } setExtractStatus('saving title...'); try { const res = await patchIssue({ title: newTitle }); if (!res.ok) { setExtractStatus('error: ' + (res.error || 'failed'), 'error'); return; } currentIssue.title = res.issue.title; $('gitea-detail-title').textContent = currentIssue.title; cancelTitleEdit(); setExtractStatus('title updated', 'success'); // Also update the row in the list updateListRow(); } catch (err) { setExtractStatus('error: ' + err.message, 'error'); } } // ── Body edit ───────────────────────────────────────────────────── function startBodyEdit() { if (!currentIssue) return; $('gitea-body-display').classList.add('hidden'); $('gitea-body-display').classList.remove('flex'); $('gitea-body-edit').classList.remove('hidden'); $('gitea-body-input').value = currentIssue.body || ''; $('gitea-body-input').focus(); } function cancelBodyEdit() { $('gitea-body-display').classList.remove('hidden'); $('gitea-body-display').classList.add('flex'); $('gitea-body-edit').classList.add('hidden'); } async function saveBodyEdit() { if (!currentIssue) return; const newBody = $('gitea-body-input').value; if (newBody === (currentIssue.body || '')) { cancelBodyEdit(); return; } setExtractStatus('saving body...'); try { const res = await patchIssue({ body: newBody }); if (!res.ok) { setExtractStatus('error: ' + (res.error || 'failed'), 'error'); return; } currentIssue.body = res.issue.body; // Re-render body section const bodyEl = $('gitea-detail-body'); const bodyForRender = rewriteAttachmentPaths(currentIssue.body, currentAccountId); if (typeof hyprcrd.markdown.render === 'function' && bodyForRender) { bodyEl.innerHTML = hyprcrd.markdown.render(bodyForRender); } else { bodyEl.innerHTML = bodyForRender ? `
${escapeHtml(bodyForRender)}
` : '(no description)'; } cancelBodyEdit(); setExtractStatus('body updated', 'success'); } catch (err) { setExtractStatus('error: ' + err.message, 'error'); } } // ── State toggle (close / reopen) ──────────────────────────────── async function toggleState() { if (!currentIssue) return; const newState = currentIssue.state === 'open' ? 'closed' : 'open'; setExtractStatus(newState === 'closed' ? 'closing...' : 'reopening...'); try { const res = await patchIssue({ state: newState }); if (!res.ok) { setExtractStatus('error: ' + (res.error || 'failed'), 'error'); return; } currentIssue.state = res.issue.state; // Update the badge + button const badge = $('gitea-detail-state'); badge.className = 'gitea-state-badge ' + currentIssue.state; badge.textContent = currentIssue.state; renderStateToggleButton(); setExtractStatus(newState === 'closed' ? 'issue closed' : 'issue reopened', 'success'); updateListRow(); } catch (err) { setExtractStatus('error: ' + err.message, 'error'); } } function renderStateToggleButton() { const btn = $('gitea-state-toggle'); if (!btn || !currentIssue) return; if (currentIssue.state === 'open') { btn.innerHTML = 'Close'; } else { btn.innerHTML = 'Reopen'; } } // ── Labels editor ───────────────────────────────────────────────── let availableRepoLabels = []; async function openLabelsEditor() { if (!currentIssue) return; 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 = '
loading labels...
'; try { const res = await fetch(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/labels`); const data = await res.json(); if (!data.ok) { list.innerHTML = `
${escapeHtml(data.error || 'failed to load')}
`; 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) const selectedNames = new Set((currentIssue.labels || []).map(l => l.name)); if (!availableRepoLabels.length) { list.innerHTML = '
repo has no labels
'; return; } list.innerHTML = availableRepoLabels.map(l => ` `).join(''); } catch (err) { list.innerHTML = `
${escapeHtml(err.message)}
`; } } function closeLabelsEditor() { $('gitea-labels-editor').classList.add('hidden'); } async function saveLabels() { if (!currentIssue) return; const checked = []; document.querySelectorAll('#gitea-labels-checkboxes .gitea-label-checkbox').forEach(el => { const cb = el.querySelector('input'); if (cb && cb.checked) checked.push(parseInt(el.dataset.id)); }); setExtractStatus('saving labels...'); try { const res = await hyprcrd.api(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/${currentIssue.number}/labels`, { method: 'PUT', body: { labels: checked }, }); if (!res.ok) { setExtractStatus('error: ' + (res.error || 'failed'), 'error'); return; } currentIssue.labels = res.labels || []; // Re-render labels area $('gitea-detail-labels').innerHTML = (currentIssue.labels || []).map(l => `${escapeHtml(l.name)}` ).join(''); closeLabelsEditor(); setExtractStatus('labels updated', 'success'); updateListRow(); } catch (err) { setExtractStatus('error: ' + err.message, 'error'); } } // ── Comment posting ─────────────────────────────────────────────── async function postComment() { if (!currentIssue) return; const input = $('gitea-comment-input'); const body = input.value.trim(); if (!body) return; const status = $('gitea-comment-status'); status.textContent = 'posting...'; status.style.color = 'var(--t-dim)'; try { const res = await hyprcrd.api(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/${currentIssue.number}/comment`, { method: 'POST', body: { body }, }); if (!res.ok) { status.textContent = 'error: ' + (res.error || 'failed'); status.style.color = 'var(--t-danger)'; return; } // Append the new comment to local state and re-render currentIssue.comments = [...(currentIssue.comments || []), res.comment]; renderCommentsSection(); input.value = ''; status.textContent = 'posted'; status.style.color = 'var(--t-accent)'; setTimeout(() => { status.textContent = ''; }, 1500); } catch (err) { status.textContent = 'error: ' + err.message; status.style.color = 'var(--t-danger)'; } } function renderCommentsSection() { if (!currentIssue) return; const section = $('gitea-detail-comments-section'); const commentsEl = $('gitea-detail-comments'); const comments = currentIssue.comments || []; if (comments.length > 0) { section.classList.remove('hidden'); $('gitea-detail-comments-count').textContent = comments.length; commentsEl.innerHTML = comments.map(c => { const bodyForRender = rewriteAttachmentPaths(c.body, currentAccountId); const renderedBody = (typeof hyprcrd.markdown.render === 'function' && bodyForRender) ? hyprcrd.markdown.render(bodyForRender) : `
${escapeHtml(bodyForRender || '')}
`; return `
${escapeHtml(c.author || '?')} ${escapeHtml(formatRelative(c.created_at))}
${renderedBody}
`; }).join(''); } else { section.classList.add('hidden'); commentsEl.innerHTML = ''; } } // ── PATCH issue helper ──────────────────────────────────────────── async function patchIssue(changes) { return hyprcrd.api(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/${currentIssue.number}`, { method: 'PATCH', body: changes, }); } // ── Sync the affected issue row in the list after an edit ──────── function updateListRow() { if (!currentIssue) return; const row = document.querySelector(`.gitea-issue-row[data-owner="${currentIssue.owner}"][data-repo="${currentIssue.repo_name}"][data-number="${currentIssue.number}"]`); if (!row) return; // Update the cached object in currentIssues const cached = currentIssues.find(i => i.repo === currentIssue.repo && i.number === currentIssue.number); if (cached) { cached.title = currentIssue.title; cached.state = currentIssue.state; cached.labels = currentIssue.labels; } // Re-render just that row by re-rendering the whole list (cheap enough) renderIssueList(); } // ── Extract → card ──────────────────────────────────────────────── async function extractIssue() { if (!currentIssue) return; const status = $('gitea-extract-status'); status.textContent = 'creating...'; status.style.color = 'var(--t-dim)'; const i = currentIssue; const title = `[${i.repo}#${i.number}] ${i.title || '(no title)'}`; const lines = [ `**Repo:** ${i.repo}`, `**Number:** #${i.number}`, `**State:** ${i.state}`, `**Author:** ${i.author || ''}`, `**Created:** ${i.created_at || ''}`, ]; if (i.assignees && i.assignees.length) lines.push(`**Assignees:** ${i.assignees.join(', ')}`); if (i.labels && i.labels.length) lines.push(`**Labels:** ${i.labels.map(l => l.name).join(', ')}`); if (i.milestone) lines.push(`**Milestone:** ${i.milestone}`); if (i.html_url) lines.push(`**URL:** ${i.html_url}`); lines.push('', '---', '', i.body || '_(no description)_'); if (i.comments && i.comments.length > 0) { lines.push('', '---', '', `## Comments (${i.comments.length})`, ''); for (const c of i.comments) { lines.push(`**${c.author || '?'}** — ${c.created_at || ''}`, '', c.body || '', ''); } } lines.push('', '#gitea #issue'); const body = lines.join('\n'); try { const res = await hyprcrd.api('api/cards', { method: 'POST', body: { title, body, color: '#22c55e' }, }); if (res.error) { status.textContent = 'error: ' + res.error; status.style.color = 'var(--t-danger)'; return; } status.textContent = 'card created'; status.style.color = 'var(--t-accent)'; window._reloadAll?.(); } catch (err) { status.textContent = 'error: ' + err.message; status.style.color = 'var(--t-danger)'; } } // ── Resizable divider ───────────────────────────────────────────── function initDivider() { const divider = $('gitea-divider'); const left = $('gitea-list-pane'); if (!divider || !left) return; let dragging = false; divider.addEventListener('mousedown', e => { dragging = true; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; const rect = $('gitea-view-issues').getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; if (pct > 15 && pct < 75) left.style.width = pct + '%'; }); window.addEventListener('mouseup', () => { dragging = false; }); } // ── Helpers ──────────────────────────────────────────────────────── function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function formatDateTime(s) { if (!s) return ''; const d = new Date(s); return isNaN(d.getTime()) ? s : d.toLocaleString(); } function formatRelative(s) { if (!s) return ''; const d = new Date(s); if (isNaN(d.getTime())) return s.substring(0, 10); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return `${Math.floor(diff)}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`; return d.toLocaleDateString(); } // ── Header button injection ─────────────────────────────────────── function injectHeaderButton() { const header = document.querySelector('#main-header .flex.items-center.gap-3:last-of-type'); if (!header) return; if (document.getElementById('gitea-btn')) return; const btn = document.createElement('button'); btn.id = 'gitea-btn'; btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; btn.innerHTML = 'Gitea'; btn.addEventListener('click', openGitea); const addBtn = document.getElementById('add-btn'); if (addBtn) header.insertBefore(btn, addBtn); else header.appendChild(btn); } // ── Init ────────────────────────────────────────────────────────── function init() { if (!$('gitea-overlay')) return; $('gitea-close').addEventListener('click', closeGitea); $('gitea-overlay').addEventListener('click', e => { if (e.target === $('gitea-overlay')) closeGitea(); }); $('gitea-overlay').addEventListener('keydown', e => { if (e.key === 'Escape') { closeGitea(); e.stopPropagation(); } }); $('gitea-account-select').addEventListener('change', e => { currentAccountId = parseInt(e.target.value); currentPage = 1; currentIssue = null; cachedRepos = null; cachedReposForAccount = null; cachedReposError = null; $('gitea-detail').classList.add('hidden'); $('gitea-detail').classList.remove('flex'); $('gitea-create-form').classList.add('hidden'); $('gitea-create-form').classList.remove('flex'); $('gitea-detail-empty').classList.remove('hidden'); loadIssues(); }); $('gitea-show-settings').addEventListener('click', gotoAccountsSettings); $('gitea-welcome-configure').addEventListener('click', gotoAccountsSettings); // Issue list controls $('gitea-refresh').addEventListener('click', loadIssues); $('gitea-filter-state').addEventListener('change', e => { currentState = e.target.value; currentPage = 1; loadIssues(); }); $('gitea-filter-scope').addEventListener('change', e => { currentScope = e.target.value; currentPage = 1; loadIssues(); }); let queryDebounce = null; $('gitea-filter-q').addEventListener('input', e => { clearTimeout(queryDebounce); queryDebounce = setTimeout(() => { currentQuery = e.target.value.trim(); currentPage = 1; loadIssues(); }, 350); }); $('gitea-page-prev').addEventListener('click', () => { if (currentPage > 1) { currentPage--; loadIssues(); } }); $('gitea-page-next').addEventListener('click', () => { if (currentHasMore) { currentPage++; loadIssues(); } }); // Create issue $('gitea-new-issue').addEventListener('click', openCreateForm); $('gitea-create-cancel').addEventListener('click', closeCreateForm); $('gitea-create-cancel-2').addEventListener('click', closeCreateForm); $('gitea-create-submit').addEventListener('click', submitCreateIssue); // 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); $('gitea-state-toggle').addEventListener('click', toggleState); // Title inline edit $('gitea-title-edit-btn').addEventListener('click', startTitleEdit); $('gitea-title-save').addEventListener('click', saveTitleEdit); $('gitea-title-cancel').addEventListener('click', cancelTitleEdit); $('gitea-title-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); saveTitleEdit(); } if (e.key === 'Escape') { e.preventDefault(); cancelTitleEdit(); } }); // Body inline edit $('gitea-body-edit-btn').addEventListener('click', startBodyEdit); $('gitea-body-save').addEventListener('click', saveBodyEdit); $('gitea-body-cancel').addEventListener('click', cancelBodyEdit); $('gitea-body-input').addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); cancelBodyEdit(); } if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveBodyEdit(); } }); // Labels editor $('gitea-labels-edit-btn').addEventListener('click', openLabelsEditor); $('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 => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); postComment(); } }); initDivider(); injectHeaderButton(); // Register the "gitea" tab inside the core settings overlay hyprcrd.settings.registerTab({ id: 'gitea', label: 'gitea', icon: 'ph-git-pull-request', onShow: (panelEl) => refreshSettingsPanel(panelEl), }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } hyprcrd.registerPlugin({ name: 'gitea', version: '0.1.0', description: 'Gitea issue browser (scaffold)', });