This commit is contained in:
th committed 2026-04-10 11:46:15 +02:00
1 parent cc97754ae6
commit 86508ffcca
13 files changed
+945 -35

No files matched your search

Submodule gitea-issue-client updated: 3d0c2f3a82...90353c93ad.
+1 -1
View File
@@ -1421,8 +1421,8 @@ if (str_starts_with($route, '/api/')) {
</div>
<div class="flex items-center gap-3">
<button id="caldav-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-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="caldav-test-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="caldav-test-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
<div id="caldav-calendars" class="hidden border-t border-t-border pt-2">
<label class="font-pixel text-[9px] text-t-dim uppercase block mb-2">calendars</label>
<div id="caldav-cal-list" class="space-y-2"></div>
+2
View File
@@ -95,6 +95,8 @@
getPostProcessors() {
return postProcessors.slice();
},
// Filled in by core markdown.js after load
render: null,
};
// ── Util helpers (mirror of internal util.js) ───────────────────
+26 -6
View File
@@ -120,9 +120,31 @@ export async function loadSettings() {
}
function setCaldavTestStatus(text, kind = 'info') {
if (!testStatus) return;
if (!text) {
testStatus.classList.add('hidden');
testStatus.textContent = '';
testStatus.style.borderColor = '';
testStatus.style.color = '';
return;
}
testStatus.classList.remove('hidden');
testStatus.textContent = text;
if (kind === 'error') {
testStatus.style.borderColor = 'var(--t-danger)';
testStatus.style.color = 'var(--t-danger)';
} else if (kind === 'success') {
testStatus.style.borderColor = 'var(--t-accent)';
testStatus.style.color = 'var(--t-accent)';
} else {
testStatus.style.borderColor = 'var(--t-border)';
testStatus.style.color = 'var(--t-muted)';
}
}
$('caldav-test')?.addEventListener('click', async () => {
testStatus.textContent = 'testing...';
testStatus.style.color = 'var(--t-dim)';
setCaldavTestStatus('testing...');
const body = { url: urlInput.value, username: userInput.value };
if (passInput.value) body.password = passInput.value;
const res = await fetch('api/caldav/test', {
@@ -132,12 +154,10 @@ $('caldav-test')?.addEventListener('click', async () => {
if (res.ok) {
const names = (res.calendars || []).map(c => c.name).join(', ');
testStatus.textContent = `connected (${names || 'no calendars'})`;
testStatus.style.color = 'var(--t-accent)';
setCaldavTestStatus(`connected (${names || 'no calendars'})`, 'success');
if (res.calendars) renderCalList(res.calendars);
} else {
testStatus.textContent = res.error || 'failed';
testStatus.style.color = 'var(--t-danger)';
setCaldavTestStatus(res.error || 'failed', 'error');
}
showConnectionLog(res.log);
});
+5
View File
@@ -168,6 +168,11 @@ export function renderMd(src) {
return html;
}
// Expose to plugins via the hyprcrd API
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
window.hyprcrd.markdown.render = renderMd;
}
export function addCodeCopyButtons(container) {
if (!container) return;
container.querySelectorAll('pre > code').forEach(code => {
+38 -27
View File
@@ -168,8 +168,8 @@ function buildSettingsPanel(panelEl) {
<button type="submit" id="email-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="email-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="email-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>
<span id="email-acc-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<div id="email-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>
@@ -233,6 +233,30 @@ function renderAccountsList() {
});
}
function setAccountStatus(text, kind = 'info') {
const el = $('email-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 = $('email-account-form');
@@ -246,7 +270,7 @@ function resetAccountForm() {
$('email-acc-username').value = '';
$('email-acc-password').value = '';
$('email-acc-folder').value = 'INBOX';
$('email-acc-status').textContent = '';
setAccountStatus('');
$('email-pw-hint').textContent = '';
}
@@ -264,7 +288,7 @@ function editAccount(id) {
$('email-acc-password').value = '';
$('email-acc-folder').value = acc.folder;
$('email-pw-hint').textContent = '(leave blank to keep existing)';
$('email-acc-status').textContent = '';
setAccountStatus('');
$('email-acc-name').focus();
}
@@ -283,9 +307,7 @@ async function deleteAccount(id) {
async function saveAccount(e) {
e.preventDefault();
const status = $('email-acc-status');
status.textContent = 'saving...';
status.style.color = 'var(--t-dim)';
setAccountStatus('saving...');
const payload = {
name: $('email-acc-name').value.trim(),
@@ -297,8 +319,7 @@ async function saveAccount(e) {
folder: $('email-acc-folder').value.trim() || 'INBOX',
};
if (!payload.name || !payload.host || !payload.username || (!editingAccountId && !payload.password)) {
status.textContent = 'name, host, username, and password are required';
status.style.color = 'var(--t-danger)';
setAccountStatus('name, host, username, and password are required', 'error');
return;
}
if (editingAccountId && !payload.password) delete payload.password;
@@ -311,45 +332,35 @@ async function saveAccount(e) {
res = await hyprcrd.api('api/email/accounts', { method: 'POST', body: payload });
}
if (res.error) {
status.textContent = 'error: ' + res.error;
status.style.color = 'var(--t-danger)';
setAccountStatus('error: ' + res.error, 'error');
return;
}
status.textContent = 'saved';
status.style.color = 'var(--t-accent)';
setAccountStatus('saved', 'success');
await loadAccounts();
renderAccountsList();
resetAccountForm();
setTimeout(() => { resetAccountForm(); }, 1500);
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
setAccountStatus('error: ' + err.message, 'error');
}
}
async function testExistingAccount(id) {
const status = $('email-acc-status');
status.textContent = 'testing...';
status.style.color = 'var(--t-dim)';
setAccountStatus('testing...');
try {
const res = await hyprcrd.api(`api/email/accounts/${id}/test`, { method: 'POST' });
if (res.ok) {
status.textContent = `ok — ${res.exists} messages in INBOX`;
status.style.color = 'var(--t-accent)';
setAccountStatus(`ok — ${res.exists} messages in INBOX`, 'success');
} else {
status.textContent = 'failed: ' + (res.error || 'unknown');
status.style.color = 'var(--t-danger)';
setAccountStatus('failed: ' + (res.error || 'unknown'), 'error');
}
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
setAccountStatus('error: ' + err.message, 'error');
}
}
async function testCurrentForm() {
if (!editingAccountId) {
const status = $('email-acc-status');
status.textContent = 'save first, then test';
status.style.color = 'var(--t-dim)';
setAccountStatus('save first, then test');
return;
}
await testExistingAccount(editingAccountId);
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* Minimal Gitea v1 REST client for the hyprcrd gitea plugin.
*
* Mirrors the Python `gitea_client.py` shape but only implements the
* subset needed for MVP: connection test, search/list/get issues, list
* comments. Write operations (create issue, comment, edit) are deferred
* to a later iteration — see TODO.md.
*
* Authentication is via the standard Gitea token header:
* Authorization: token <TOKEN>
*
* Errors throw GiteaApiException with status code + body.
*/
class GiteaApiException extends RuntimeException
{
public int $statusCode;
public string $body;
public function __construct(int $statusCode, string $body)
{
$this->statusCode = $statusCode;
$this->body = $body;
parent::__construct("Gitea API HTTP $statusCode: " . substr($body, 0, 200));
}
}
class GiteaClient
{
private string $baseUrl;
private string $token;
private int $timeout;
public function __construct(string $baseUrl, string $token, int $timeout = 20)
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->token = $token;
$this->timeout = $timeout;
}
/**
* Verify the token works against the configured base URL by hitting
* /user. Returns the authenticated user dict on success.
* Throws GiteaApiException on failure.
*/
public function testConnection(): array
{
$resp = $this->request('GET', '/user');
return is_array($resp) ? $resp : [];
}
/**
* Cross-repo issue search. Mirrors Python search_issues().
* @param array $opts Any of: state, labels, milestones, q, type,
* since, before, assigned, created, mentioned,
* review_requested, reviewed, owner, team,
* page, limit
*/
public function searchIssues(array $opts = []): array
{
$resp = $this->request('GET', '/repos/issues/search', $opts);
return is_array($resp) ? $resp : [];
}
public function listRepoIssues(string $owner, string $repo, array $opts = []): array
{
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues';
$resp = $this->request('GET', $path, $opts);
return is_array($resp) ? $resp : [];
}
public function getIssue(string $owner, string $repo, int $index): array
{
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index;
$resp = $this->request('GET', $path);
return is_array($resp) ? $resp : [];
}
public function listIssueComments(string $owner, string $repo, int $index, array $opts = []): array
{
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments';
$resp = $this->request('GET', $path, $opts);
return is_array($resp) ? $resp : [];
}
// ── Low-level request ─────────────────────────────────────────
private function request(string $method, string $path, array $query = [], ?array $payload = null)
{
$url = $this->baseUrl . '/api/v1' . $path;
$cleanQuery = [];
foreach ($query as $k => $v) {
if ($v === null || $v === '') continue;
if (is_bool($v)) $v = $v ? 'true' : 'false';
$cleanQuery[$k] = $v;
}
if ($cleanQuery) {
$url .= '?' . http_build_query($cleanQuery);
}
$ch = curl_init($url);
$headers = [
'Accept: application/json',
'Authorization: token ' . $this->token,
'User-Agent: hyprcrd-gitea-plugin/1.0',
];
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
];
if ($payload !== null) {
$headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = json_encode($payload);
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$body = curl_exec($ch);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("Could not reach Gitea: $err");
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
curl_close($ch);
if ($status >= 400) {
throw new GiteaApiException((int)$status, (string)$body);
}
if ($body === '' || $body === null) return null;
if (stripos($contentType, 'application/json') !== false || preg_match('/^\s*[\[\{]/', (string)$body)) {
$decoded = json_decode((string)$body, true);
return $decoded;
}
return $body;
}
}
+99
View File
@@ -0,0 +1,99 @@
# gitea plugin — roadmap
> **Status:** scaffold. Phase 1 = accounts CRUD + connection test only.
> The `gitea-issue-client/` Python tool in the project root is the
> reference for the full feature surface that needs to be ported.
## Phase 1 — done ✅
- [x] `GiteaClient.php` cURL wrapper with `testConnection()`,
`searchIssues()`, `listRepoIssues()`, `getIssue()`, `listIssueComments()`
- [x] `gitea_accounts` table with encrypted token (vault key, same as email)
- [x] Routes: `GET/POST/PATCH/DELETE /api/gitea/accounts` + `POST .../test`
- [x] Settings tab via `hyprcrd.settings.registerTab`
- [x] Header button "gitea"
- [x] Welcome view + accounts management form
- [x] Connection test against `/api/v1/user`
## Phase 2 — issue reader (next)
- [ ] **Issue list view** in the overlay:
- Search box (`q` parameter)
- State filter (open/closed/all)
- Owner/team filter (optional dropdown)
- Pagination (reuse pattern from email plugin)
- Each row: title, repo path, state badge, labels, author, age
- [ ] **Issue detail view** (right pane):
- Subject + state + labels + assignees + milestone
- Body (markdown rendered with the existing core renderer)
- Comments list (each with author + timestamp)
- "Open on Gitea" link button
- [ ] **Extract → card**:
- Card title = issue title
- Card body = markdown with metadata header + body + comments + `#gitea` `#issue` tags
- Optionally store backref `(account_id, owner, repo, index)` in a
`gitea_issue_links` table for later sync
## Phase 3 — write operations
- [ ] **Add comment** from the detail view
- [ ] **Close / reopen** issue
- [ ] **Edit title / body** (only for the issue creator)
- [ ] **Set labels** (need to fetch repo labels, multi-select UI)
- [ ] **Set assignees** (typeahead from repo collaborators)
- [ ] **Set milestone**
- [ ] **Set due date**
## Phase 4 — create issues
- [ ] **"new issue" button** in the overlay
- [ ] **Create from card**: a card → push as new gitea issue, store the
backref so future edits can sync both ways
- [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md`
if available
## Phase 5 — bidirectional sync (the killer feature)
- [ ] When a card has a gitea backref, an "issue" badge appears
- [ ] Edit the card body in hyprcrd → "push to gitea" button
- [ ] "pull from gitea" button refreshes the card from the live issue
- [ ] Conflict detection via `updated_at` timestamps
- [ ] This replaces the Python tool's `gir push` workflow
## Phase 6 — multi-instance + ergonomics
- [ ] Account selector remembers last-used account in localStorage
- [ ] Per-account default filters (e.g. always show `assigned=true`)
- [ ] Org/repo browsing tree on the left
- [ ] Label browser per repo
- [ ] Milestone view
- [ ] Search across multiple accounts
## Hardening
- [ ] Rate limiting on test endpoint
- [ ] Better error messages (currently raw HTTP body leaks through)
- [ ] Cert pinning option for self-hosted gitea instances
- [ ] Connection caching (currently every request opens a new cURL handle)
- [ ] Map common errors (401 → "token expired", 403 → "no access", etc.)
## Mapping from the Python tool
| Python feature | hyprcrd plan |
|----------------------------------------|-------------------------------------------|
| `gi search` | overlay issue list with `q` filter |
| `gi list --owner --repo` | overlay with owner/repo filter |
| `gi get` | overlay detail pane |
| `gi create` | Phase 4 "new issue" button |
| `gi edit` | Phase 3 detail-pane edit buttons |
| `gi comment` | Phase 3 comment box in detail pane |
| `gi comments` | Phase 2 comments rendering |
| `gir fetch` → markdown files | Phase 2 extract-as-card (no files needed) |
| `gir push` → sync local edits | Phase 5 bidirectional sync |
| `gir new --push` | Phase 4 create-from-card |
| `gir clean` | not needed (cards are managed via the UI) |
| `_valid-metadata.md` per repo | label/milestone dropdowns at edit time |
| `templates/gir-new-issue.md` | configurable issue template per account |
Once Phase 2–4 are done, the entire `gitea-issue-client/` Python tool
can be removed from the project root.
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* Gitea Plugin: backend.
*
* Replaces the standalone Python `gitea-issue-client` tool. MVP scope:
* accounts CRUD + connection test. Issue browsing/extraction comes once
* the connection layer is verified. See TODO.md for the deferred items.
*
* Routes:
* GET /api/gitea/accounts → list accounts (no tokens)
* POST /api/gitea/accounts → create account
* PATCH /api/gitea/accounts/{id} → update (omit token to keep)
* DELETE /api/gitea/accounts/{id} → remove
* POST /api/gitea/accounts/{id}/test → verify token via /api/v1/user
*/
require_once __DIR__ . '/GiteaClient.php';
// ── Schema migration ──────────────────────────────────────────────
(function () {
$db = Database::connect();
$db->exec("
CREATE TABLE IF NOT EXISTS gitea_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
token TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
");
})();
// ── Encryption helpers (mirrors email plugin / CalDavSync) ────────
function giteaEncKey(): string {
$db = Database::connect();
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
if ($vk) return substr(hash('sha256', $vk), 0, 32);
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
function giteaEncrypt(string $plain): string {
$key = giteaEncKey();
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
function giteaDecrypt(string $encoded): string {
$key = giteaEncKey();
$raw = base64_decode($encoded);
if (!$raw || strlen($raw) < 17) return '';
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
}
function giteaJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function giteaGetAccount(int $id): ?array {
$db = Database::connect();
$stmt = $db->prepare('SELECT * FROM gitea_accounts WHERE id = :id');
$stmt->execute(['id' => $id]);
return $stmt->fetch() ?: null;
}
function giteaOpenClient(array $row): GiteaClient {
return new GiteaClient($row['base_url'], giteaDecrypt($row['token']));
}
// ── Route registration ────────────────────────────────────────────
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
// ── Accounts CRUD ──
if ($route === '/api/gitea/accounts' && $method === 'GET') {
$db = Database::connect();
$rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll();
giteaJson($rows);
}
if ($route === '/api/gitea/accounts' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$name = trim((string)($input['name'] ?? ''));
$baseUrl = trim((string)($input['base_url'] ?? ''));
$token = (string)($input['token'] ?? '');
if ($name === '' || $baseUrl === '' || $token === '') {
giteaJson(['error' => 'name, base_url, and token are required'], 400);
}
// Normalize base URL: strip trailing slash, accept with/without scheme
if (!preg_match('#^https?://#i', $baseUrl)) {
$baseUrl = 'https://' . $baseUrl;
}
$baseUrl = rtrim($baseUrl, '/');
$db = Database::connect();
$stmt = $db->prepare('
INSERT INTO gitea_accounts (name, base_url, token)
VALUES (:n, :u, :t)
');
$stmt->execute([
'n' => $name,
'u' => $baseUrl,
't' => giteaEncrypt($token),
]);
giteaJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
$id = (int)$m[1];
$row = giteaGetAccount($id);
if (!$row) giteaJson(['error' => 'account not found'], 404);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$fields = []; $params = ['id' => $id];
if (array_key_exists('name', $input)) {
$fields[] = 'name = :name'; $params['name'] = trim((string)$input['name']);
}
if (array_key_exists('base_url', $input)) {
$u = trim((string)$input['base_url']);
if (!preg_match('#^https?://#i', $u)) $u = 'https://' . $u;
$fields[] = 'base_url = :base_url'; $params['base_url'] = rtrim($u, '/');
}
if (!empty($input['token'])) {
$fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']);
}
if (!$fields) giteaJson(['ok' => true]);
$db = Database::connect();
$db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
giteaJson(['ok' => true]);
}
if (preg_match('#^/api/gitea/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
$id = (int)$m[1];
$db = Database::connect();
$db->prepare('DELETE FROM gitea_accounts WHERE id = :id')->execute(['id' => $id]);
giteaJson(['ok' => true]);
}
if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
$id = (int)$m[1];
$row = giteaGetAccount($id);
if (!$row) giteaJson(['error' => 'account not found'], 404);
try {
$client = giteaOpenClient($row);
$user = $client->testConnection();
giteaJson([
'ok' => true,
'user' => [
'login' => $user['login'] ?? null,
'full_name' => $user['full_name'] ?? null,
'email' => $user['email'] ?? null,
'avatar_url' => $user['avatar_url'] ?? null,
],
'base_url' => $row['base_url'],
]);
} catch (GiteaApiException $e) {
giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200);
} catch (\Throwable $e) {
giteaJson(['ok' => false, 'error' => $e->getMessage()], 200);
}
}
});
+47
View File
@@ -0,0 +1,47 @@
/* Gitea Plugin styles */
#gitea-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
/* Account list (in settings tab) */
.gitea-account-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--t-border);
background: var(--t-surface);
border-radius: 2px;
}
.gitea-account-row .gitea-account-info {
flex: 1;
min-width: 0;
}
.gitea-account-row .gitea-account-name {
font-family: var(--t-font-pixel);
font-size: 11px;
color: var(--t-bright);
}
.gitea-account-row .gitea-account-meta {
font-size: 10px;
color: var(--t-muted);
font-family: monospace;
margin-top: 2px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.gitea-account-actions {
display: flex;
gap: 4px;
}
/* Override Chrome/Safari autofill on the gitea token field */
#gitea-acc-token:-webkit-autofill,
#gitea-acc-token:-webkit-autofill:hover,
#gitea-acc-token:-webkit-autofill:focus {
-webkit-text-fill-color: var(--t-bright);
-webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset;
box-shadow: 0 0 0 1000px var(--t-bg) inset;
caret-color: var(--t-bright);
transition: background-color 9999s ease-out, color 9999s ease-out;
}
+47
View File
@@ -0,0 +1,47 @@
<!-- Gitea Plugin: full-screen issue browser overlay -->
<div class="overlay fixed inset-0" id="gitea-overlay" style="z-index:75;">
<div class="gitea-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
<h2 class="font-pixel text-xs text-t-accent">gitea</h2>
<select id="gitea-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
<span id="gitea-status" class="text-[10px] text-t-dim font-pixel"></span>
<div class="flex-1"></div>
<button id="gitea-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">accounts</span></button>
<button id="gitea-close" class="flex items-center gap-1.5 px-2 py-1 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]">close</span></button>
</div>
<!-- ── Welcome view (shown when no accounts) ── -->
<div id="gitea-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
<div class="flex items-center justify-center h-full px-6 py-12">
<div class="text-center max-w-md">
<i class="ph ph-git-pull-request text-t-accent text-6xl block mb-4"></i>
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to gitea</h3>
<p class="text-xs text-t-muted leading-relaxed mb-6">
Read issues from one or more Gitea instances inside hyprcrd. Issues stay
on the server — only the ones you explicitly extract become cards.
To get started, add at least one account in settings.
</p>
<button id="gitea-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
<i class="ph ph-gear text-sm"></i>
<span class="font-pixel text-[11px]">configure accounts</span>
</button>
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → gitea</p>
</div>
</div>
</div>
<!-- ── Issues reader (placeholder for now — filled in next phase) ── -->
<div id="gitea-view-issues" class="hidden flex-1 min-h-0 overflow-hidden">
<div class="flex items-center justify-center h-full px-6 text-center">
<div>
<i class="ph ph-list-bullets text-t-dim text-4xl block mb-3"></i>
<p class="font-pixel text-[10px] text-t-dim">issue reader coming next</p>
<p class="text-[10px] text-t-dim mt-2">Connection works — UI is the next step.</p>
</div>
</div>
</div>
</div>
</div>
+352
View File
@@ -0,0 +1,352 @@
// ── 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;
// ── 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();
}
}
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>
<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)}</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 = '';
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);
}
// ── Helpers ────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// ── 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);
});
$('gitea-show-settings').addEventListener('click', gotoAccountsSettings);
$('gitea-welcome-configure').addEventListener('click', gotoAccountsSettings);
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)',
});
+11
View File
@@ -0,0 +1,11 @@
{
"name": "gitea",
"version": "0.1.0",
"description": "Gitea issue browser. Read issues and comments from one or more Gitea instances, extract them as cards. Multiple accounts supported, tokens encrypted with the vault key.",
"frontend": {
"js": ["gitea.js"],
"css": ["gitea.css"],
"html": ["gitea.html"]
},
"backend": "backend.php"
}