Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
Prefix enforcement prevents extension CSS from leaking into the kernel
or sibling extensions. All 12 in-tree packages migrated to .ext-{slug}-*
naming convention.
- Add data-ext attribute to extension mount container
- Add CSS linter (scripts/lint-package-css.sh) enforcing .ext-{slug} prefix
- Add kernel CSS contract doc (docs/EXTENSION-CSS.md)
- Migrate 12 packages: chat, dashboard, editor, git-board, hello-dashboard,
icd-test-runner, notes, schedules, sdk-test-runner, tasks,
team-activity-log, workflow-demo (CSS + JS in lockstep)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
492 lines
19 KiB
JavaScript
492 lines
19 KiB
JavaScript
/**
|
|
* Git Board — Surface Entry Point
|
|
*
|
|
* Kanban board for Gitea issues and PRs.
|
|
* Calls Starlark backend via /s/git-board/api/*.
|
|
* Authentication via gitea-client library connections.
|
|
*
|
|
* Features: DnD between columns, issue detail modal with comments.
|
|
*/
|
|
(async function () {
|
|
'use strict';
|
|
|
|
var mount = document.getElementById('extension-mount');
|
|
if (!mount) return;
|
|
|
|
var base = window.__BASE__ || '';
|
|
var ver = window.__VERSION__ || '0';
|
|
var slug = 'git-board';
|
|
|
|
// ── Boot SDK ───────────────────────────────
|
|
try {
|
|
if (!window.preact) {
|
|
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
|
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
|
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
|
window.preact = { h, render };
|
|
window.hooks = hooksModule;
|
|
window.html = htmModule.default.bind(h);
|
|
}
|
|
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
|
await sdk.boot();
|
|
} catch (e) {
|
|
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
|
return;
|
|
}
|
|
|
|
var { html } = window;
|
|
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
|
|
var { render } = preact;
|
|
|
|
var API = base + '/s/' + slug + '/api';
|
|
|
|
async function api(path, opts) {
|
|
var token = sw.auth._getToken();
|
|
var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } };
|
|
if (opts && opts.method) fetchOpts.method = opts.method;
|
|
if (opts && opts.body) {
|
|
fetchOpts.body = JSON.stringify(opts.body);
|
|
fetchOpts.headers['Content-Type'] = 'application/json';
|
|
}
|
|
var resp = await fetch(API + path, fetchOpts);
|
|
var text = await resp.text();
|
|
try { return JSON.parse(text); } catch (_) { return { error: text }; }
|
|
}
|
|
|
|
// ── App ────────────────────────────────────
|
|
|
|
function App() {
|
|
var [owner, setOwner] = useState('');
|
|
var [repo, setRepo] = useState('');
|
|
var [repos, setRepos] = useState([]);
|
|
var [board, setBoard] = useState(null);
|
|
var [loading, setLoading] = useState(false);
|
|
var [needsConn, setNeedsConn] = useState(false);
|
|
var [modal, setModal] = useState(null); // {owner, repo, number}
|
|
var menuRef = useRef(null);
|
|
|
|
useEffect(function () {
|
|
if (menuRef.current && sw.userMenu) {
|
|
sw.userMenu(menuRef.current, { placement: 'down-left' });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(function () {
|
|
api('/repos').then(function (d) {
|
|
if (d.error) {
|
|
if (d.error.indexOf('no gitea connection configured') !== -1) {
|
|
setNeedsConn(true);
|
|
} else { sw.toast(d.error, 'error'); }
|
|
return;
|
|
}
|
|
setRepos(d.data || []);
|
|
if (d.data && d.data.length > 0 && !owner) {
|
|
setOwner(d.data[0].owner);
|
|
setRepo(d.data[0].name);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
var loadBoard = useCallback(function () {
|
|
if (!owner || !repo) return;
|
|
setLoading(true);
|
|
api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo))
|
|
.then(function (d) {
|
|
if (d.error) {
|
|
sw.toast(d.error, 'error');
|
|
if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true);
|
|
} else {
|
|
setBoard(d);
|
|
setNeedsConn(false);
|
|
}
|
|
setLoading(false);
|
|
});
|
|
}, [owner, repo]);
|
|
|
|
useEffect(function () { loadBoard(); }, [loadBoard]);
|
|
|
|
var onDrop = useCallback(function (issueNumber, targetCol) {
|
|
if (!board) return;
|
|
var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; });
|
|
if (!issue) return;
|
|
|
|
var patch = {};
|
|
if (targetCol === 'open') {
|
|
// Move to Open: unassign + reopen
|
|
if (issue.state === 'closed') patch.state = 'open';
|
|
// Note: Gitea PATCH /issues doesn't support clearing assignee via empty string,
|
|
// but we do our best — the board will re-split on refresh.
|
|
} else if (targetCol === 'in_progress') {
|
|
// Move to In Progress: assign to current user + ensure open
|
|
if (issue.state === 'closed') patch.state = 'open';
|
|
// Gitea needs assignees array — not supported by our simple update_issue.
|
|
// For now, just reopen. User assigns via modal.
|
|
if (issue.state === 'closed') patch.state = 'open';
|
|
} else if (targetCol === 'done') {
|
|
patch.state = 'closed';
|
|
}
|
|
|
|
if (!patch.state) return; // no meaningful change
|
|
|
|
// Optimistic update
|
|
var newIssues = (board.issues || []).map(function (i) {
|
|
if (i.number !== issueNumber) return i;
|
|
var copy = {};
|
|
for (var k in i) copy[k] = i[k];
|
|
if (patch.state) copy.state = patch.state;
|
|
return copy;
|
|
});
|
|
setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] });
|
|
|
|
api('/issue/' + owner + '/' + repo + '/' + issueNumber, {
|
|
method: 'POST', body: patch
|
|
}).then(function (d) {
|
|
if (d.error) {
|
|
sw.toast('Update failed: ' + d.error, 'error');
|
|
loadBoard(); // revert
|
|
}
|
|
});
|
|
}, [board, owner, repo, loadBoard]);
|
|
|
|
var openModal = useCallback(function (number) {
|
|
setModal({ owner: owner, repo: repo, number: number });
|
|
}, [owner, repo]);
|
|
|
|
var closeModal = useCallback(function (changed) {
|
|
setModal(null);
|
|
if (changed) loadBoard();
|
|
}, [loadBoard]);
|
|
|
|
return html`
|
|
<div class="user-menu-container" ref=${menuRef}></div>
|
|
${needsConn ? html`<${ConnectionSetup} />` : html`
|
|
<div class="ext-git-board-shell">
|
|
<header class="ext-git-board-header">
|
|
<div class="ext-git-board-header__left">
|
|
<h1 class="ext-git-board-title">Git Board</h1>
|
|
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
|
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
|
</div>
|
|
<div class="ext-git-board-header__right">
|
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
|
|
${loading ? '↻' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
|
<div class="ext-git-board-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
|
`}
|
|
</div>
|
|
`}
|
|
${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
|
|
`;
|
|
}
|
|
|
|
// ── Connection Setup ─────────────────────────
|
|
|
|
function ConnectionSetup() {
|
|
return html`
|
|
<div class="ext-git-board-shell">
|
|
<header class="ext-git-board-header">
|
|
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
|
|
</header>
|
|
<div class="ext-git-board-setup">
|
|
<h2>Connect to Gitea</h2>
|
|
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
|
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
|
<strong>Admin → Connections</strong>, or add a personal one in
|
|
<strong>Settings → Connections</strong>.</p>
|
|
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
|
onClick=${function () { window.location.href = base + '/settings'; }}>
|
|
Open Settings
|
|
</button>
|
|
<p class="ext-git-board-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Repo Picker ────────────────────────────
|
|
|
|
function RepoPicker({ repos, owner, repo, onSelect }) {
|
|
var current = owner + '/' + repo;
|
|
return html`
|
|
<select class="ext-git-board-repo-picker" value=${current}
|
|
onChange=${function (e) {
|
|
var parts = e.target.value.split('/');
|
|
onSelect(parts[0], parts.slice(1).join('/'));
|
|
}}>
|
|
${repos.map(function (r) {
|
|
return html`<option key=${r.full_name} value=${r.full_name}>${r.full_name}</option>`;
|
|
})}
|
|
</select>
|
|
`;
|
|
}
|
|
|
|
// ── Kanban Board with DnD ─────────────────
|
|
|
|
function Board({ data, onDrop, onCardClick }) {
|
|
var issues = data.issues || [];
|
|
var prs = data.pull_requests || [];
|
|
|
|
var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
|
|
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
|
|
|
return html`
|
|
<div class="ext-git-board-board">
|
|
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
|
${openUnassigned.map(function (i) {
|
|
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
|
})}
|
|
<//>
|
|
<${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}>
|
|
${inProgress.map(function (i) {
|
|
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
|
})}
|
|
<//>
|
|
<${Column} id="done" title="Done" count=${0} onDrop=${onDrop}>
|
|
<//>
|
|
<${Column} id="prs" title="Pull Requests" count=${prs.length}>
|
|
${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
|
|
<//>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function Column({ id, title, count, children, onDrop }) {
|
|
var [over, setOver] = useState(false);
|
|
|
|
var handlers = onDrop ? {
|
|
onDragOver: function (e) { e.preventDefault(); setOver(true); },
|
|
onDragLeave: function () { setOver(false); },
|
|
onDrop: function (e) {
|
|
e.preventDefault();
|
|
setOver(false);
|
|
var num = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
|
if (num && onDrop) onDrop(num, id);
|
|
}
|
|
} : {};
|
|
|
|
return html`
|
|
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
|
|
<div class="ext-git-board-column__header">
|
|
<span class="ext-git-board-column__title">${title}</span>
|
|
<span class="ext-git-board-column__count">${count || 0}</span>
|
|
</div>
|
|
<div class="ext-git-board-column__cards">${children}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function IssueCard({ issue, onClick }) {
|
|
return html`
|
|
<div class="ext-git-board-card" draggable="true"
|
|
onDragStart=${function (e) {
|
|
e.dataTransfer.setData('text/plain', String(issue.number));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}}
|
|
onClick=${function (e) {
|
|
e.preventDefault();
|
|
if (onClick) onClick(issue.number);
|
|
}}>
|
|
<div class="ext-git-board-card__header">
|
|
<span class="ext-git-board-card__number">#${issue.number}</span>
|
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
|
</div>
|
|
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
|
|
<div class="ext-git-board-card__labels">
|
|
${(issue.labels || []).map(function (l) {
|
|
return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`;
|
|
})}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function PRCard({ pr }) {
|
|
return html`
|
|
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
|
<div class="ext-git-board-card__header">
|
|
<span class="ext-git-board-card__number">#${pr.number}</span>
|
|
<span class="ext-git-board-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
|
</div>
|
|
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
|
|
<div class="ext-git-board-card__meta">
|
|
<span>@${esc(pr.user)}</span>
|
|
<span>${timeAgo(pr.created_at)}</span>
|
|
</div>
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
// ── Issue Detail Modal ────────────────────
|
|
|
|
function IssueModal({ owner, repo, number, onClose }) {
|
|
var [issue, setIssue] = useState(null);
|
|
var [loading, setLoading] = useState(true);
|
|
var [comment, setComment] = useState('');
|
|
var [posting, setPosting] = useState(false);
|
|
var [changed, setChanged] = useState(false);
|
|
var bodyRef = useRef(null);
|
|
|
|
useEffect(function () {
|
|
setLoading(true);
|
|
api('/issue/' + owner + '/' + repo + '/' + number).then(function (d) {
|
|
if (d.error) { sw.toast(d.error, 'error'); onClose(false); return; }
|
|
setIssue(d);
|
|
setLoading(false);
|
|
});
|
|
}, [owner, repo, number]);
|
|
|
|
// Close on Escape
|
|
useEffect(function () {
|
|
var handler = function (e) { if (e.key === 'Escape') onClose(changed); };
|
|
document.addEventListener('keydown', handler);
|
|
return function () { document.removeEventListener('keydown', handler); };
|
|
}, [changed]);
|
|
|
|
var postComment = useCallback(function () {
|
|
if (!comment.trim() || posting) return;
|
|
setPosting(true);
|
|
api('/issue/' + owner + '/' + repo + '/' + number + '/comment', {
|
|
method: 'POST', body: { body: comment.trim() }
|
|
}).then(function (d) {
|
|
if (d.error) { sw.toast(d.error, 'error'); }
|
|
else {
|
|
// Append to local comments
|
|
setIssue(function (prev) {
|
|
if (!prev) return prev;
|
|
var copy = {};
|
|
for (var k in prev) copy[k] = prev[k];
|
|
copy.comments = (prev.comments || []).concat([d]);
|
|
return copy;
|
|
});
|
|
setComment('');
|
|
setChanged(true);
|
|
}
|
|
setPosting(false);
|
|
});
|
|
}, [comment, posting, owner, repo, number]);
|
|
|
|
var toggleState = useCallback(function () {
|
|
if (!issue) return;
|
|
var newState = issue.state === 'open' ? 'closed' : 'open';
|
|
api('/issue/' + owner + '/' + repo + '/' + number, {
|
|
method: 'POST', body: { state: newState }
|
|
}).then(function (d) {
|
|
if (d.error) { sw.toast(d.error, 'error'); return; }
|
|
setIssue(function (prev) {
|
|
if (!prev) return prev;
|
|
var copy = {};
|
|
for (var k in prev) copy[k] = prev[k];
|
|
copy.state = newState;
|
|
return copy;
|
|
});
|
|
setChanged(true);
|
|
});
|
|
}, [issue, owner, repo, number]);
|
|
|
|
return html`
|
|
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
|
|
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
|
|
}}>
|
|
<div class="ext-git-board-modal">
|
|
<div class="ext-git-board-modal__header">
|
|
<div class="ext-git-board-modal__title-row">
|
|
${loading ? 'Loading…' : html`
|
|
<span class="ext-git-board-card__number">#${number}</span>
|
|
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
|
|
`}
|
|
</div>
|
|
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
|
</div>
|
|
|
|
${!loading && issue && html`
|
|
<div class="ext-git-board-modal__body" ref=${bodyRef}>
|
|
<div class="ext-git-board-modal__meta">
|
|
<span class="ext-git-board-badge ${issue.state === 'open' ? 'ext-git-board-badge--green' : 'ext-git-board-badge--muted'}">${issue.state}</span>
|
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
|
${issue.created_at && html`<span class="ext-git-board-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
|
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
|
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
|
|
</div>
|
|
|
|
${issue.labels && issue.labels.length > 0 && html`
|
|
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
|
|
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
|
|
</div>
|
|
`}
|
|
|
|
<div class="ext-git-board-modal__description">
|
|
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
|
|
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
|
|
</div>
|
|
|
|
<div class="ext-git-board-modal__comments">
|
|
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
|
${(issue.comments || []).length === 0 && html`
|
|
<p class="ext-git-board-modal__empty">No comments yet.</p>
|
|
`}
|
|
${(issue.comments || []).map(function (c, i) {
|
|
return html`
|
|
<div class="ext-git-board-comment" key=${i}>
|
|
<div class="ext-git-board-comment__header">
|
|
<strong>@${esc(c.user)}</strong>
|
|
<span class="ext-git-board-comment__date">${timeAgo(c.created_at)}</span>
|
|
</div>
|
|
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
|
|
</div>
|
|
`;
|
|
})}
|
|
</div>
|
|
|
|
<div class="ext-git-board-modal__add-comment">
|
|
<textarea class="ext-git-board-modal__textarea" rows="3"
|
|
placeholder="Add a comment…"
|
|
value=${comment}
|
|
onInput=${function (e) { setComment(e.target.value); }}
|
|
onKeyDown=${function (e) {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
|
}} />
|
|
<div class="ext-git-board-modal__actions">
|
|
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
|
|
onClick=${postComment}>
|
|
${posting ? 'Posting…' : 'Comment'}
|
|
</button>
|
|
<button class="sw-btn sw-btn--secondary sw-btn--sm ${issue.state === 'open' ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--secondary sw-btn--md'}"
|
|
onClick=${toggleState}>
|
|
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Utilities ──────────────────────────────
|
|
|
|
function esc(s) {
|
|
var el = document.createElement('span');
|
|
el.textContent = String(s || '');
|
|
return el.innerHTML;
|
|
}
|
|
|
|
function timeAgo(iso) {
|
|
if (!iso) return '';
|
|
var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (sec < 60) return 'now';
|
|
var min = Math.floor(sec / 60);
|
|
if (min < 60) return min + 'm';
|
|
var hr = Math.floor(min / 60);
|
|
if (hr < 24) return hr + 'h';
|
|
return Math.floor(hr / 24) + 'd';
|
|
}
|
|
|
|
// ── Mount ──────────────────────────────────
|
|
render(html`<${App} />`, mount);
|
|
console.log('[git-board] Surface mounted');
|
|
})();
|