Gitea plugin: default_repo field per account (stored in DB, pre-fills create issue form but stays editable). Editor toolbar: asset picker flyout with 4-column thumbnail grid and live search filter. Clicking a tile inserts the matching markdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1640 lines
69 KiB
JavaScript
1640 lines
69 KiB
JavaScript
// ── 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 => `<option value="${a.id}">${escapeHtml(a.name)}</option>`).join('')
|
|
: '<option value="">— no accounts —</option>';
|
|
}
|
|
|
|
// ── Settings tab: rendering ───────────────────────────────────────
|
|
|
|
function buildSettingsPanel(panelEl) {
|
|
panelEl.innerHTML = `
|
|
<div class="space-y-4">
|
|
<div>
|
|
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-git-pull-request text-xs"></i> gitea accounts</div>
|
|
<p class="text-[10px] text-t-dim leading-relaxed mb-3">
|
|
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 <span class="font-mono">Settings → Applications</span>.
|
|
</p>
|
|
<div id="gitea-accounts-list" class="space-y-2"></div>
|
|
</div>
|
|
<div class="border-t border-t-border pt-4">
|
|
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><span id="gitea-form-title">add account</span></div>
|
|
<form id="gitea-account-form" class="space-y-2">
|
|
<input type="hidden" id="gitea-acc-id" value="">
|
|
<label class="block">
|
|
<span class="font-pixel text-[9px] text-t-dim uppercase">name</span>
|
|
<input id="gitea-acc-name" type="text" placeholder="My Gitea" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
|
</label>
|
|
<label class="block">
|
|
<span class="font-pixel text-[9px] text-t-dim uppercase">base url</span>
|
|
<input id="gitea-acc-baseurl" type="text" placeholder="https://git.example.com" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
|
</label>
|
|
<label class="block">
|
|
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
|
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
|
</label>
|
|
<label class="block">
|
|
<span class="font-pixel text-[9px] text-t-dim uppercase">default repo <span class="text-t-muted normal-case text-[8px]">(owner/repo)</span></span>
|
|
<input id="gitea-acc-default-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
|
</label>
|
|
<div class="flex items-center gap-2 pt-1">
|
|
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
|
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
|
<button type="button" id="gitea-acc-cancel" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Cancel</span></button>
|
|
</div>
|
|
<div id="gitea-acc-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<p class="text-xs text-t-dim italic">No accounts configured yet. Use the form below to add one.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = accounts.map(a => `
|
|
<div class="gitea-account-row" data-id="${a.id}">
|
|
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
|
<div class="gitea-account-info">
|
|
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
|
<div class="gitea-account-meta">${escapeHtml(a.base_url)}${a.default_repo ? ' · ' + escapeHtml(a.default_repo) : ''}</div>
|
|
</div>
|
|
<div class="gitea-account-actions">
|
|
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
|
<button class="toolbar-btn gitea-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
|
|
<button class="toolbar-btn gitea-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
|
|
</div>
|
|
</div>
|
|
`).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 = '';
|
|
$('gitea-acc-default-repo').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-acc-default-repo').value = acc.default_repo || '';
|
|
$('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,
|
|
default_repo: $('gitea-acc-default-repo').value.trim(),
|
|
};
|
|
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 = '<div class="flex items-center justify-center py-12"><i class="ph ph-spinner card-spinner" style="font-size:2.25rem;color:var(--t-muted);opacity:0.4;"></i></div>';
|
|
$('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 = `<div class="p-4 text-xs text-t-danger break-words">${escapeHtml(data.error || 'failed to load')}</div>`;
|
|
$('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 = `<div class="p-4 text-xs text-t-danger break-words">${escapeHtml(err.message)}</div>`;
|
|
$('gitea-status').textContent = 'error';
|
|
}
|
|
}
|
|
|
|
function renderIssueList() {
|
|
const list = $('gitea-issue-list');
|
|
if (!currentIssues.length) {
|
|
list.innerHTML = '<div class="p-4 text-xs text-t-dim italic">no issues</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = currentIssues.map(issue => {
|
|
const [owner, repoName] = (issue.repo || '/').split('/');
|
|
return `
|
|
<div class="gitea-issue-row state-${issue.state}" data-owner="${escapeHtml(owner)}" data-repo="${escapeHtml(repoName)}" data-number="${issue.number}">
|
|
<div class="gitea-issue-repo">${escapeHtml(issue.repo || '')}</div>
|
|
<div class="gitea-issue-title">${escapeHtml(issue.title || '(no title)')}</div>
|
|
${issue.labels.length ? `<div class="gitea-issue-labels">${issue.labels.map(l => `<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>`).join('')}</div>` : ''}
|
|
<div class="gitea-issue-meta">
|
|
<span class="gitea-state-badge ${issue.state}">${issue.state}</span>
|
|
<span>#${issue.number}</span>
|
|
<span>${escapeHtml(issue.author || '')}</span>
|
|
${issue.comments_count > 0 ? `<span><i class="ph ph-chat-circle text-[10px]"></i> ${issue.comments_count}</span>` : ''}
|
|
<span class="text-t-dim">${escapeHtml(formatRelative(issue.updated_at || issue.created_at))}</span>
|
|
</div>
|
|
</div>
|
|
`;}).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 <img src="/attachments/<uuid>"> or
|
|
//  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/<uuid>... 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 =>
|
|
`<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>`
|
|
).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 ? `<pre style="white-space:pre-wrap">${escapeHtml(bodyForRender)}</pre>` : '<em class="text-t-dim">(no description)</em>';
|
|
}
|
|
|
|
// 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(`<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');
|
|
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 — pre-fill default repo if configured
|
|
const currentAccount = accounts.find(a => a.id === currentAccountId);
|
|
$('gitea-create-repo').value = currentAccount?.default_repo || '';
|
|
$('gitea-create-title').value = '';
|
|
$('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 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');
|
|
}
|
|
}
|
|
|
|
if (currentAccount?.default_repo) loadCreateRepoLabels();
|
|
}
|
|
|
|
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) => `
|
|
<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('/');
|
|
if (!owner || !repo) {
|
|
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...</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');
|
|
}
|
|
if (!labels.length) {
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
} 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>`;
|
|
}
|
|
}
|
|
|
|
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
|
|
? `<pre style="white-space:pre-wrap">${escapeHtml(bodyForRender)}</pre>`
|
|
: '<em class="text-t-dim">(no description)</em>';
|
|
}
|
|
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 = '<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]">Close</span>';
|
|
} else {
|
|
btn.innerHTML = '<i class="ph ph-arrow-counter-clockwise text-sm"></i><span class="font-pixel text-[10px]">Reopen</span>';
|
|
}
|
|
}
|
|
|
|
// ── 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 = '<div class="text-[10px] text-t-dim italic">loading labels...</div>';
|
|
|
|
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 = `<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)
|
|
const selectedNames = new Set((currentIssue.labels || []).map(l => l.name));
|
|
if (!availableRepoLabels.length) {
|
|
list.innerHTML = '<div class="text-[10px] text-t-dim italic">repo has no labels</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = availableRepoLabels.map(l => `
|
|
<label class="gitea-label-checkbox" data-id="${l.id}">
|
|
<input type="checkbox" ${selectedNames.has(l.name) ? 'checked' : ''}>
|
|
<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>
|
|
${l.description ? `<span class="text-[9px] text-t-dim ml-1">${escapeHtml(l.description)}</span>` : ''}
|
|
</label>
|
|
`).join('');
|
|
} catch (err) {
|
|
list.innerHTML = `<div class="text-[10px] text-t-danger">${escapeHtml(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
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 =>
|
|
`<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>`
|
|
).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)
|
|
: `<pre style="white-space:pre-wrap">${escapeHtml(bodyForRender || '')}</pre>`;
|
|
return `
|
|
<div class="gitea-comment">
|
|
<div class="gitea-comment-header">
|
|
<span class="gitea-comment-author">${escapeHtml(c.author || '?')}</span>
|
|
<span>${escapeHtml(formatRelative(c.created_at))}</span>
|
|
</div>
|
|
<div class="gitea-comment-body prose">${renderedBody}</div>
|
|
</div>
|
|
`;
|
|
}).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 = '<i class="ph ph-git-pull-request text-sm"></i><span class="font-pixel text-[10px]">Gitea</span>';
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Open the gitea overlay, switch to the create-issue form and pre-fill
|
|
* title + body from a hyprcrd card. Does NOT submit — the user reviews
|
|
* and clicks Submit themselves. Called from the card right-click menu's
|
|
* "Make ticket" action (see public/js/modules/context-menu.js).
|
|
*/
|
|
window._giteaMakeIssueFromCard = async function (card) {
|
|
if (!card) return;
|
|
// Body might not be loaded in state.cards (lazy field) — fetch it.
|
|
let body = card.body;
|
|
if (body == null) {
|
|
try {
|
|
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
|
const fresh = all.find(c => c.id == card.id);
|
|
body = fresh?.body || '';
|
|
} catch { body = ''; }
|
|
}
|
|
await openGitea();
|
|
if (accounts.length === 0) return; // welcome view — user must configure first
|
|
await openCreateForm();
|
|
const titleInput = $('gitea-create-title');
|
|
const bodyInput = $('gitea-create-body');
|
|
if (titleInput) titleInput.value = card.title || '';
|
|
if (bodyInput) bodyInput.value = body || '';
|
|
// Cursor on the repo field so the user can pick the destination
|
|
// first. Title + body are already filled.
|
|
$('gitea-create-repo')?.focus();
|
|
};
|
|
|
|
hyprcrd.registerPlugin({
|
|
name: 'gitea',
|
|
version: '0.1.0',
|
|
description: 'Gitea issue browser (scaffold)',
|
|
});
|