gitea plug
This commit is contained in:
1 parent
86508ffcca
commit
dd1afbbeb2
6 files changed
+1650
-30
No files matched your search
@@ -85,6 +85,80 @@ class GiteaClient
|
|||||||
return is_array($resp) ? $resp : [];
|
return is_array($resp) ? $resp : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Post a new comment on an issue. Returns the created comment object. */
|
||||||
|
public function createIssueComment(string $owner, string $repo, int $index, string $body): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments';
|
||||||
|
$resp = $this->request('POST', $path, [], ['body' => $body]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit an issue. Pass any subset of: title, body, state ('open'|'closed'),
|
||||||
|
* assignees (string[]), milestone (int), due_date (RFC3339), unset_due_date (bool).
|
||||||
|
* Returns the updated issue.
|
||||||
|
*/
|
||||||
|
public function editIssue(string $owner, string $repo, int $index, array $changes): array
|
||||||
|
{
|
||||||
|
$allowed = ['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'ref', 'unset_due_date'];
|
||||||
|
$payload = [];
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $changes)) $payload[$k] = $changes[$k];
|
||||||
|
}
|
||||||
|
if (!$payload) return [];
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index;
|
||||||
|
$resp = $this->request('PATCH', $path, [], $payload);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the labels of an issue. $labels is an array of label IDs (ints).
|
||||||
|
* Returns the resulting list of labels.
|
||||||
|
*/
|
||||||
|
public function replaceIssueLabels(string $owner, string $repo, int $index, array $labels): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/labels';
|
||||||
|
$resp = $this->request('PUT', $path, [], ['labels' => array_map('intval', $labels)]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the available labels for a repository. */
|
||||||
|
public function listRepoLabels(string $owner, string $repo, int $page = 1, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/labels';
|
||||||
|
$resp = $this->request('GET', $path, ['page' => $page, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new issue in a repository. Pass any of: title (required),
|
||||||
|
* body, assignees (string[]), labels (int[] of label IDs), milestone (int),
|
||||||
|
* due_date (RFC3339), ref, closed (bool).
|
||||||
|
* Returns the created issue.
|
||||||
|
*/
|
||||||
|
public function createIssue(string $owner, string $repo, array $opts): array
|
||||||
|
{
|
||||||
|
if (empty($opts['title'])) return [];
|
||||||
|
$allowed = ['title', 'body', 'assignees', 'labels', 'milestone', 'due_date', 'ref', 'closed'];
|
||||||
|
$payload = [];
|
||||||
|
foreach ($allowed as $k) {
|
||||||
|
if (array_key_exists($k, $opts) && $opts[$k] !== null) $payload[$k] = $opts[$k];
|
||||||
|
}
|
||||||
|
$path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues';
|
||||||
|
$resp = $this->request('POST', $path, [], $payload);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List repositories the authenticated user has access to.
|
||||||
|
* Used to populate the repo picker for "new issue".
|
||||||
|
*/
|
||||||
|
public function listMyRepos(int $page = 1, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$resp = $this->request('GET', '/user/repos', ['page' => $page, 'limit' => $limit]);
|
||||||
|
return is_array($resp) ? $resp : [];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Low-level request ─────────────────────────────────────────
|
// ── Low-level request ─────────────────────────────────────────
|
||||||
|
|
||||||
private function request(string $method, string $path, array $query = [], ?array $payload = null)
|
private function request(string $method, string $path, array $query = [], ?array $payload = null)
|
||||||
|
|||||||
@@ -15,42 +15,64 @@
|
|||||||
- [x] Welcome view + accounts management form
|
- [x] Welcome view + accounts management form
|
||||||
- [x] Connection test against `/api/v1/user`
|
- [x] Connection test against `/api/v1/user`
|
||||||
|
|
||||||
## Phase 2 — issue reader (next)
|
## Phase 2 — issue reader ✅
|
||||||
|
|
||||||
- [ ] **Issue list view** in the overlay:
|
- [x] **Issue list view** in the overlay:
|
||||||
- Search box (`q` parameter)
|
- Search box (`q` parameter, debounced 350ms)
|
||||||
- State filter (open/closed/all)
|
- State filter (open/closed/all)
|
||||||
- Owner/team filter (optional dropdown)
|
- Pagination (prev/next, hasMore heuristic since Gitea doesn't always return total)
|
||||||
- Pagination (reuse pattern from email plugin)
|
|
||||||
- Each row: title, repo path, state badge, labels, author, age
|
- Each row: title, repo path, state badge, labels, author, age
|
||||||
- [ ] **Issue detail view** (right pane):
|
- [x] **Issue detail view** (right pane):
|
||||||
- Subject + state + labels + assignees + milestone
|
- Subject + state badge + labels + #number + repo
|
||||||
- Body (markdown rendered with the existing core renderer)
|
- Body (markdown rendered via `hyprcrd.markdown.render` — exposed by core)
|
||||||
- Comments list (each with author + timestamp)
|
- Comments list (each with author + relative timestamp + rendered markdown)
|
||||||
- "Open on Gitea" link button
|
- "Open on Gitea" link button
|
||||||
- [ ] **Extract → card**:
|
- [x] **Extract → card**:
|
||||||
- Card title = issue title
|
- Card title = `[repo#number] title`
|
||||||
- Card body = markdown with metadata header + body + comments + `#gitea` `#issue` tags
|
- Card body = metadata header + body + comments + `#gitea #issue` tags
|
||||||
- Optionally store backref `(account_id, owner, repo, index)` in a
|
- Color: green (#22c55e)
|
||||||
`gitea_issue_links` table for later sync
|
- [ ] **Issue → card backlink** in a `gitea_issue_links` table — needed
|
||||||
|
for Phase 5 bidirectional sync
|
||||||
|
|
||||||
## Phase 3 — write operations
|
## Phase 3 — write operations
|
||||||
|
|
||||||
- [ ] **Add comment** from the detail view
|
- [x] **Add comment** — composer textarea always visible at the bottom of the
|
||||||
- [ ] **Close / reopen** issue
|
detail pane. Ctrl/Cmd+Enter posts. New comment is appended to local state
|
||||||
- [ ] **Edit title / body** (only for the issue creator)
|
and re-rendered immediately.
|
||||||
- [ ] **Set labels** (need to fetch repo labels, multi-select UI)
|
- [x] **Close / reopen** — single button in the action bar that toggles state.
|
||||||
- [ ] **Set assignees** (typeahead from repo collaborators)
|
Updates the badge, button label, and the row in the list.
|
||||||
- [ ] **Set milestone**
|
- [x] **Edit title** — pencil icon next to the title swaps to an inline input.
|
||||||
- [ ] **Set due date**
|
Enter saves, Escape cancels. PATCH `/issues/{index}`.
|
||||||
|
- [x] **Edit body** — pencil icon next to the body swaps to a textarea.
|
||||||
|
Ctrl/Cmd+Enter saves, Escape cancels.
|
||||||
|
- [x] **Set labels** — tag icon opens an inline editor that fetches the repo's
|
||||||
|
label list (`GET /api/gitea/repo/.../labels`) and shows checkboxes with
|
||||||
|
original colors. Save uses `PUT /issues/{index}/labels`.
|
||||||
|
- [ ] **Set assignees** — needs a collaborator typeahead (next round, see
|
||||||
|
TODO Phase 3.5)
|
||||||
|
- [ ] **Set milestone** — needs milestone fetch + dropdown (next round)
|
||||||
|
- [ ] **Set due date** — date picker (next round)
|
||||||
|
|
||||||
|
## Phase 3.5 — remaining metadata
|
||||||
|
|
||||||
|
- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones`
|
||||||
|
- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators`
|
||||||
|
- [ ] Milestone dropdown in detail view
|
||||||
|
- [ ] Assignee multi-select with typeahead
|
||||||
|
- [ ] Due date input + clear button
|
||||||
|
|
||||||
## Phase 4 — create issues
|
## Phase 4 — create issues
|
||||||
|
|
||||||
- [ ] **"new issue" button** in the overlay
|
- [x] **"new issue" button** in the list toolbar
|
||||||
|
- [x] **Repo picker** loaded from `/user/repos` (cached per account, filtered
|
||||||
|
to non-archived repos with push permission)
|
||||||
|
- [x] **Title + body + labels** form
|
||||||
|
- [x] **Submit** creates via POST `/repo/{accId}/{owner}/{repo}/issues`,
|
||||||
|
refreshes list, auto-opens the new issue in the detail view
|
||||||
- [ ] **Create from card**: a card → push as new gitea issue, store the
|
- [ ] **Create from card**: a card → push as new gitea issue, store the
|
||||||
backref so future edits can sync both ways
|
backref so future edits can sync both ways (Phase 5)
|
||||||
- [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md`
|
- [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md`
|
||||||
if available
|
if available (next round)
|
||||||
|
|
||||||
## Phase 5 — bidirectional sync (the killer feature)
|
## Phase 5 — bidirectional sync (the killer feature)
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,371 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
giteaJson(['ok' => true]);
|
giteaJson(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Issues: list ──
|
||||||
|
|
||||||
|
if ($route === '/api/gitea/issues' && $method === 'GET') {
|
||||||
|
$accId = (int)($_GET['account'] ?? 0);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
|
||||||
|
$state = in_array($_GET['state'] ?? 'open', ['open', 'closed', 'all'], true) ? $_GET['state'] : 'open';
|
||||||
|
$q = trim((string)($_GET['q'] ?? ''));
|
||||||
|
$owner = trim((string)($_GET['owner'] ?? ''));
|
||||||
|
$scope = $_GET['scope'] ?? 'all'; // all | assigned | created | mentioned
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = max(5, min(100, (int)($_GET['limit'] ?? 30)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$issues = $client->searchIssues([
|
||||||
|
'state' => $state,
|
||||||
|
'q' => $q !== '' ? $q : null,
|
||||||
|
'owner' => $owner !== '' ? $owner : null,
|
||||||
|
'type' => 'issues', // exclude PRs
|
||||||
|
'assigned' => $scope === 'assigned' ? true : null,
|
||||||
|
'created' => $scope === 'created' ? true : null,
|
||||||
|
'mentioned' => $scope === 'mentioned' ? true : null,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Map to a compact, UI-friendly shape
|
||||||
|
$mapped = array_map(function ($i) {
|
||||||
|
$repoFull = $i['repository']['full_name'] ?? null;
|
||||||
|
if (!$repoFull && isset($i['repository']['owner'], $i['repository']['name'])) {
|
||||||
|
$repoFull = $i['repository']['owner'] . '/' . $i['repository']['name'];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => $i['id'] ?? null,
|
||||||
|
'number' => $i['number'] ?? null,
|
||||||
|
'title' => $i['title'] ?? '',
|
||||||
|
'state' => $i['state'] ?? 'open',
|
||||||
|
'created_at' => $i['created_at'] ?? null,
|
||||||
|
'updated_at' => $i['updated_at'] ?? null,
|
||||||
|
'comments_count' => $i['comments'] ?? 0,
|
||||||
|
'html_url' => $i['html_url'] ?? null,
|
||||||
|
'repo' => $repoFull,
|
||||||
|
'author' => $i['user']['login'] ?? null,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
], $i['labels'] ?? []),
|
||||||
|
'assignees' => array_map(fn($a) => $a['login'] ?? '', $i['assignees'] ?? []),
|
||||||
|
];
|
||||||
|
}, $issues);
|
||||||
|
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issues' => $mapped,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'hasMore' => count($mapped) >= $limit, // heuristic — Gitea doesn't always return total
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue detail (with comments) ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1];
|
||||||
|
$owner = urldecode($m[2]);
|
||||||
|
$repo = urldecode($m[3]);
|
||||||
|
$index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$issue = $client->getIssue($owner, $repo, $index);
|
||||||
|
$comments = $client->listIssueComments($owner, $repo, $index);
|
||||||
|
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issue' => [
|
||||||
|
'id' => $issue['id'] ?? null,
|
||||||
|
'number' => $issue['number'] ?? $index,
|
||||||
|
'title' => $issue['title'] ?? '',
|
||||||
|
'body' => $issue['body'] ?? '',
|
||||||
|
'state' => $issue['state'] ?? 'open',
|
||||||
|
'created_at' => $issue['created_at'] ?? null,
|
||||||
|
'updated_at' => $issue['updated_at'] ?? null,
|
||||||
|
'closed_at' => $issue['closed_at'] ?? null,
|
||||||
|
'due_date' => $issue['due_date'] ?? null,
|
||||||
|
'html_url' => $issue['html_url'] ?? null,
|
||||||
|
'author' => $issue['user']['login'] ?? null,
|
||||||
|
'author_avatar' => $issue['user']['avatar_url'] ?? null,
|
||||||
|
'repo' => "$owner/$repo",
|
||||||
|
'owner' => $owner,
|
||||||
|
'repo_name' => $repo,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
], $issue['labels'] ?? []),
|
||||||
|
'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []),
|
||||||
|
'milestone' => $issue['milestone']['title'] ?? null,
|
||||||
|
],
|
||||||
|
'comments' => array_map(fn($c) => [
|
||||||
|
'id' => $c['id'] ?? null,
|
||||||
|
'author' => $c['user']['login'] ?? null,
|
||||||
|
'body' => $c['body'] ?? '',
|
||||||
|
'created_at' => $c['created_at'] ?? null,
|
||||||
|
'updated_at' => $c['updated_at'] ?? null,
|
||||||
|
], $comments),
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue write operations ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/comment$#', $route, $m) && $method === 'POST') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$body = trim((string)($input['body'] ?? ''));
|
||||||
|
if ($body === '') giteaJson(['error' => 'comment body required'], 400);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$created = $client->createIssueComment($owner, $repo, $index, $body);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'comment' => [
|
||||||
|
'id' => $created['id'] ?? null,
|
||||||
|
'author' => $created['user']['login'] ?? null,
|
||||||
|
'body' => $created['body'] ?? '',
|
||||||
|
'created_at' => $created['created_at'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$changes = [];
|
||||||
|
foreach (['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'unset_due_date'] as $k) {
|
||||||
|
if (array_key_exists($k, $input)) $changes[$k] = $input[$k];
|
||||||
|
}
|
||||||
|
if (!$changes) giteaJson(['error' => 'no changes'], 400);
|
||||||
|
if (isset($changes['state']) && !in_array($changes['state'], ['open', 'closed'], true)) {
|
||||||
|
giteaJson(['error' => 'invalid state'], 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$updated = $client->editIssue($owner, $repo, $index, $changes);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issue' => [
|
||||||
|
'title' => $updated['title'] ?? '',
|
||||||
|
'body' => $updated['body'] ?? '',
|
||||||
|
'state' => $updated['state'] ?? 'open',
|
||||||
|
'updated_at' => $updated['updated_at'] ?? null,
|
||||||
|
'closed_at' => $updated['closed_at'] ?? null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/labels$#', $route, $m) && $method === 'PUT') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4];
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$labels = $input['labels'] ?? [];
|
||||||
|
if (!is_array($labels)) giteaJson(['error' => 'labels array required'], 400);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$resulting = $client->replaceIssueLabels($owner, $repo, $index, $labels);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'id' => $l['id'] ?? null,
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
], $resulting),
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachment proxy ──
|
||||||
|
// Gitea attachments live under /attachments/<uuid> on the gitea instance
|
||||||
|
// and require the auth token (or session cookie). The browser can't fetch
|
||||||
|
// them directly (CORS + no token), so hyprcrd proxies them.
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/attachment/(\d+)/(.+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1];
|
||||||
|
$path = $m[2]; // e.g. "attachments/abc-123-uuid" or "user/repo/raw/branch/file.png"
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
// Build target URL on the gitea instance
|
||||||
|
$url = rtrim($row['base_url'], '/') . '/' . ltrim($path, '/');
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: token ' . giteaDecrypt($row['token']),
|
||||||
|
'User-Agent: hyprcrd-gitea-plugin/1.0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
http_response_code(502);
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
echo "Could not reach gitea: $err";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream';
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($code >= 400) {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
echo "Gitea returned HTTP $code";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip any default headers PHP might emit so we can replace them
|
||||||
|
header_remove('Pragma');
|
||||||
|
header_remove('Expires');
|
||||||
|
header('Content-Type: ' . $contentType);
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
header('Content-Length: ' . strlen($body));
|
||||||
|
echo $body;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create issue ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/issues$#', $route, $m) && $method === 'POST') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$title = trim((string)($input['title'] ?? ''));
|
||||||
|
if ($title === '') giteaJson(['error' => 'title is required'], 400);
|
||||||
|
$opts = ['title' => $title];
|
||||||
|
if (array_key_exists('body', $input)) $opts['body'] = (string)$input['body'];
|
||||||
|
if (array_key_exists('labels', $input) && is_array($input['labels'])) {
|
||||||
|
$opts['labels'] = array_map('intval', $input['labels']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('assignees', $input) && is_array($input['assignees'])) {
|
||||||
|
$opts['assignees'] = array_values(array_filter(array_map('strval', $input['assignees'])));
|
||||||
|
}
|
||||||
|
if (!empty($input['milestone'])) $opts['milestone'] = (int)$input['milestone'];
|
||||||
|
if (!empty($input['due_date'])) $opts['due_date'] = (string)$input['due_date'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$created = $client->createIssue($owner, $repo, $opts);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'issue' => [
|
||||||
|
'id' => $created['id'] ?? null,
|
||||||
|
'number' => $created['number'] ?? null,
|
||||||
|
'title' => $created['title'] ?? '',
|
||||||
|
'state' => $created['state'] ?? 'open',
|
||||||
|
'html_url' => $created['html_url'] ?? null,
|
||||||
|
'owner' => $owner,
|
||||||
|
'repo_name' => $repo,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User repos (for the repo picker) ──
|
||||||
|
|
||||||
|
if ($route === '/api/gitea/repos' && $method === 'GET') {
|
||||||
|
$accId = (int)($_GET['account'] ?? 0);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$repos = $client->listMyRepos(1, 100);
|
||||||
|
$mapped = array_map(fn($r) => [
|
||||||
|
'id' => $r['id'] ?? null,
|
||||||
|
'full_name' => $r['full_name'] ?? null,
|
||||||
|
'name' => $r['name'] ?? null,
|
||||||
|
'owner' => $r['owner']['login'] ?? null,
|
||||||
|
'description' => $r['description'] ?? '',
|
||||||
|
'private' => $r['private'] ?? false,
|
||||||
|
'archived' => $r['archived'] ?? false,
|
||||||
|
'permissions' => $r['permissions'] ?? null,
|
||||||
|
], $repos);
|
||||||
|
// Filter out archived repos and ones without push permission
|
||||||
|
$mapped = array_values(array_filter($mapped, function ($r) {
|
||||||
|
if ($r['archived']) return false;
|
||||||
|
$p = $r['permissions'] ?? null;
|
||||||
|
if ($p && isset($p['push'])) return $p['push'] || $p['admin'] ?? false;
|
||||||
|
return true; // unknown permissions = include
|
||||||
|
}));
|
||||||
|
giteaJson(['ok' => true, 'repos' => $mapped]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repo metadata ──
|
||||||
|
|
||||||
|
if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/labels$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]);
|
||||||
|
$row = giteaGetAccount($accId);
|
||||||
|
if (!$row) giteaJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = giteaOpenClient($row);
|
||||||
|
$labels = $client->listRepoLabels($owner, $repo);
|
||||||
|
giteaJson([
|
||||||
|
'ok' => true,
|
||||||
|
'labels' => array_map(fn($l) => [
|
||||||
|
'id' => $l['id'] ?? null,
|
||||||
|
'name' => $l['name'] ?? '',
|
||||||
|
'color' => $l['color'] ?? '888888',
|
||||||
|
'description' => $l['description'] ?? '',
|
||||||
|
], $labels),
|
||||||
|
]);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
||||||
$id = (int)$m[1];
|
$id = (int)$m[1];
|
||||||
$row = giteaGetAccount($id);
|
$row = giteaGetAccount($id);
|
||||||
|
|||||||
@@ -35,6 +35,118 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Issue list rows */
|
||||||
|
.gitea-issue-row {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.gitea-issue-row:hover { background: var(--t-raised); }
|
||||||
|
.gitea-issue-row.active {
|
||||||
|
background: var(--t-raised);
|
||||||
|
border-left-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.gitea-issue-row .gitea-issue-repo {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
color: var(--t-dim);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.gitea-issue-row .gitea-issue-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.gitea-issue-row.state-closed .gitea-issue-title { color: var(--t-muted); }
|
||||||
|
.gitea-issue-row .gitea-issue-meta {
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gitea-issue-row .gitea-issue-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State badge (open/closed) */
|
||||||
|
.gitea-state-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.gitea-state-badge.open { background: #22c55e; color: #000; }
|
||||||
|
.gitea-state-badge.closed { background: #a855f7; color: #fff; }
|
||||||
|
|
||||||
|
/* Label chip (mirrors gitea label colors) */
|
||||||
|
.gitea-label {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment block */
|
||||||
|
.gitea-comment {
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.gitea-comment-header {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.gitea-comment-author { color: var(--t-bright); }
|
||||||
|
.gitea-comment-body {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Issue list — no text selection so dragging doesn't highlight rows */
|
||||||
|
#gitea-issue-list, #gitea-issue-list * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
#gitea-divider:hover > div { background: var(--t-accent); width: 1px; }
|
||||||
|
|
||||||
|
/* Label editor checkboxes */
|
||||||
|
.gitea-label-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.gitea-label-checkbox:hover { background: var(--t-raised); }
|
||||||
|
.gitea-label-checkbox input { accent-color: var(--t-accent); }
|
||||||
|
.gitea-label-checkbox .gitea-label { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Title input matches the h3 sizing so swap is seamless */
|
||||||
|
#gitea-title-input { font-size: 16px; font-weight: bold; line-height: 1.3; }
|
||||||
|
|
||||||
/* Override Chrome/Safari autofill on the gitea token field */
|
/* Override Chrome/Safari autofill on the gitea token field */
|
||||||
#gitea-acc-token:-webkit-autofill,
|
#gitea-acc-token:-webkit-autofill,
|
||||||
#gitea-acc-token:-webkit-autofill:hover,
|
#gitea-acc-token:-webkit-autofill:hover,
|
||||||
|
|||||||
@@ -33,13 +33,158 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Issues reader (placeholder for now — filled in next phase) ── -->
|
<!-- ── Issues reader ── -->
|
||||||
<div id="gitea-view-issues" class="hidden flex-1 min-h-0 overflow-hidden">
|
<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">
|
<!-- Issue list -->
|
||||||
<div>
|
<div id="gitea-list-pane" class="overflow-y-auto border-r border-t-border bg-t-bg shrink-0 relative" style="width:42%;">
|
||||||
<i class="ph ph-list-bullets text-t-dim text-4xl block mb-3"></i>
|
<div class="sticky top-0 z-20 bg-t-bg border-b border-t-border">
|
||||||
<p class="font-pixel text-[10px] text-t-dim">issue reader coming next</p>
|
<!-- Filters bar -->
|
||||||
<p class="text-[10px] text-t-dim mt-2">Connection works — UI is the next step.</p>
|
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||||
|
<select id="gitea-filter-state" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Issue state">
|
||||||
|
<option value="open">open</option>
|
||||||
|
<option value="closed">closed</option>
|
||||||
|
<option value="all">all</option>
|
||||||
|
</select>
|
||||||
|
<select id="gitea-filter-scope" class="bg-t-bg border border-t-border text-t-bright text-[10px] font-pixel px-1.5 py-0.5 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer" title="Filter by your involvement">
|
||||||
|
<option value="all">everyone</option>
|
||||||
|
<option value="assigned">assigned to me</option>
|
||||||
|
<option value="created">created by me</option>
|
||||||
|
<option value="mentioned">mentioning me</option>
|
||||||
|
</select>
|
||||||
|
<input id="gitea-filter-q" type="text" placeholder="search..." autocomplete="off" class="flex-1 min-w-0 bg-t-bg border border-t-border text-t-text px-2 py-0.5 text-[11px] focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
<button id="gitea-new-issue" class="toolbar-btn" title="New issue"><i class="ph ph-plus-square text-sm text-t-accent"></i></button>
|
||||||
|
<button id="gitea-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination toolbar -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1 border-t border-t-border">
|
||||||
|
<button id="gitea-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
|
<button id="gitea-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
|
<span id="gitea-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-issue-list" class="divide-y divide-t-border"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Resizer -->
|
||||||
|
<div id="gitea-divider" class="shrink-0" style="width:5px;cursor:col-resize;background:transparent;position:relative;">
|
||||||
|
<div style="position:absolute;left:2px;top:0;bottom:0;width:1px;background:var(--t-border);"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Issue detail -->
|
||||||
|
<div id="gitea-detail-pane" class="flex-1 min-w-0 min-h-0 flex flex-col bg-t-bg">
|
||||||
|
<div id="gitea-detail-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="ph ph-list-bullets text-3xl mb-2 block"></i>
|
||||||
|
select an issue
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ── Create issue form ── -->
|
||||||
|
<div id="gitea-create-form" class="hidden flex-col flex-1 min-h-0">
|
||||||
|
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<i class="ph ph-plus-square text-t-accent text-lg"></i>
|
||||||
|
<h3 class="text-t-bright text-base font-bold">new issue</h3>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="gitea-create-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">repository <span id="gitea-create-repo-hint" class="normal-case text-t-muted text-[8px]"></span></span>
|
||||||
|
<input id="gitea-create-repo" type="text" list="gitea-create-repo-list" 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">
|
||||||
|
<datalist id="gitea-create-repo-list"></datalist>
|
||||||
|
</label>
|
||||||
|
<div id="gitea-create-repo-error" class="hidden px-2 py-1.5 text-[10px] font-mono break-words border border-amber-500 text-amber-400 bg-t-bg"></div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">title</span>
|
||||||
|
<input id="gitea-create-title" type="text" placeholder="short summary" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-sm focus:border-t-accent focus:outline-none font-mono">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase">body (markdown)</span>
|
||||||
|
<textarea id="gitea-create-body" rows="14" placeholder="describe the issue..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim uppercase block mb-1">labels (optional)</span>
|
||||||
|
<div id="gitea-create-labels" class="border border-t-border bg-t-surface p-2 max-h-40 overflow-y-auto">
|
||||||
|
<div class="text-[10px] text-t-dim italic">pick a repo first</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button id="gitea-create-submit" class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">create issue</span></button>
|
||||||
|
<button id="gitea-create-cancel-2" 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-create-status" class="hidden mt-2 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-detail" class="hidden flex flex-col flex-1 min-h-0">
|
||||||
|
<!-- Detail header -->
|
||||||
|
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||||
|
<div class="flex items-start gap-3 mb-2">
|
||||||
|
<span id="gitea-detail-state" class="gitea-state-badge mt-1">open</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div id="gitea-title-display" class="flex items-center gap-2">
|
||||||
|
<h3 id="gitea-detail-title" class="text-t-bright text-base font-bold flex-1 break-words"></h3>
|
||||||
|
<button id="gitea-title-edit-btn" class="toolbar-btn shrink-0" title="Edit title"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-title-edit" class="hidden flex items-center gap-2">
|
||||||
|
<input id="gitea-title-input" type="text" class="flex-1 bg-t-bg border border-t-accent text-t-bright px-2 py-1 text-sm focus:outline-none font-mono">
|
||||||
|
<button id="gitea-title-save" class="toolbar-btn" title="Save"><i class="ph ph-check text-sm text-t-accent"></i></button>
|
||||||
|
<button id="gitea-title-cancel" class="toolbar-btn" title="Cancel"><i class="ph ph-x text-sm text-t-danger"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="gitea-detail-number" class="font-pixel text-[10px] text-t-dim mt-1"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] font-pixel text-t-dim mb-2">
|
||||||
|
<span id="gitea-detail-repo"></span>
|
||||||
|
·
|
||||||
|
<span class="text-t-muted">opened by</span> <span id="gitea-detail-author" class="text-t-text"></span>
|
||||||
|
<span id="gitea-detail-date-wrap"> · <span id="gitea-detail-date" class="text-t-text"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2 mb-2">
|
||||||
|
<div id="gitea-detail-labels" class="flex flex-wrap gap-1 flex-1"></div>
|
||||||
|
<button id="gitea-labels-edit-btn" class="toolbar-btn shrink-0" title="Edit labels"><i class="ph ph-tag text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-labels-editor" class="hidden border border-t-border bg-t-surface p-2 mb-2 max-h-48 overflow-y-auto">
|
||||||
|
<div id="gitea-labels-checkboxes" class="space-y-1"></div>
|
||||||
|
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-t-border">
|
||||||
|
<button id="gitea-labels-save" class="flex items-center gap-1.5 px-2 py-0.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 id="gitea-labels-cancel" class="flex items-center gap-1.5 px-2 py-0.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>
|
||||||
|
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<button id="gitea-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">extract as card</span></button>
|
||||||
|
<button id="gitea-state-toggle" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[10px]">close</span></button>
|
||||||
|
<a id="gitea-open-link" href="#" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-arrow-square-out text-sm"></i><span class="font-pixel text-[10px]">open on gitea</span></a>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-extract-status" class="hidden mt-2 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Body + comments scrollable -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
|
||||||
|
<div id="gitea-body-display" class="flex items-start gap-2">
|
||||||
|
<div id="gitea-detail-body" class="prose text-sm text-t-text flex-1 min-w-0"></div>
|
||||||
|
<button id="gitea-body-edit-btn" class="toolbar-btn shrink-0" title="Edit body"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-body-edit" class="hidden">
|
||||||
|
<textarea id="gitea-body-input" class="w-full bg-t-bg border border-t-accent text-t-text px-3 py-2 text-xs focus:outline-none font-mono leading-relaxed" rows="10"></textarea>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<button id="gitea-body-save" class="flex items-center gap-1.5 px-3 py-1 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 id="gitea-body-cancel" class="flex items-center gap-1.5 px-3 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]">cancel</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="gitea-detail-comments-section" class="mt-6 hidden">
|
||||||
|
<div class="font-pixel text-[10px] uppercase tracking-wider text-t-dim border-b border-t-border pb-1 mb-3"><i class="ph ph-chat-circle text-xs"></i> comments (<span id="gitea-detail-comments-count">0</span>)</div>
|
||||||
|
<div id="gitea-detail-comments" class="space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Comment composer (always visible when an issue is open) -->
|
||||||
|
<div class="mt-6 border-t border-t-border pt-4">
|
||||||
|
<div class="font-pixel text-[10px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-chat-text text-xs"></i> add comment</div>
|
||||||
|
<textarea id="gitea-comment-input" rows="3" placeholder="markdown supported..." class="w-full bg-t-bg border border-t-border text-t-text px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono leading-relaxed"></textarea>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<button id="gitea-comment-post" class="flex items-center gap-1.5 px-3 py-1 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-paper-plane-tilt text-sm"></i><span class="font-pixel text-[10px]">post</span></button>
|
||||||
|
<span id="gitea-comment-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const $ = (id) => document.getElementById(id);
|
|||||||
let accounts = [];
|
let accounts = [];
|
||||||
let currentAccountId = null;
|
let currentAccountId = null;
|
||||||
let editingAccountId = 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 ──────────────────────────────────────────
|
// ── Overlay open / close ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -32,6 +40,8 @@ async function openGitea() {
|
|||||||
}
|
}
|
||||||
$('gitea-account-select').value = currentAccountId;
|
$('gitea-account-select').value = currentAccountId;
|
||||||
showIssuesView();
|
showIssuesView();
|
||||||
|
currentPage = 1;
|
||||||
|
loadIssues();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +296,799 @@ async function testCurrentForm() {
|
|||||||
await testExistingAccount(editingAccountId);
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
setExtractStatus('');
|
||||||
|
$('gitea-comment-input').value = '';
|
||||||
|
$('gitea-comment-status').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
|
||||||
|
function setCreateStatus(text, kind = 'info') {
|
||||||
|
const el = $('gitea-create-status');
|
||||||
|
if (!el) return;
|
||||||
|
if (!text) {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
el.textContent = '';
|
||||||
|
el.style.borderColor = '';
|
||||||
|
el.style.color = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.textContent = text;
|
||||||
|
if (kind === 'error') {
|
||||||
|
el.style.borderColor = 'var(--t-danger)';
|
||||||
|
el.style.color = 'var(--t-danger)';
|
||||||
|
} else if (kind === 'success') {
|
||||||
|
el.style.borderColor = 'var(--t-accent)';
|
||||||
|
el.style.color = 'var(--t-accent)';
|
||||||
|
} else {
|
||||||
|
el.style.borderColor = 'var(--t-border)';
|
||||||
|
el.style.color = 'var(--t-muted)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateForm() {
|
||||||
|
if (!currentAccountId) return;
|
||||||
|
// Hide other detail views
|
||||||
|
$('gitea-detail').classList.add('hidden');
|
||||||
|
$('gitea-detail').classList.remove('flex');
|
||||||
|
$('gitea-detail-empty').classList.add('hidden');
|
||||||
|
$('gitea-create-form').classList.remove('hidden');
|
||||||
|
$('gitea-create-form').classList.add('flex');
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
$('gitea-create-repo').value = '';
|
||||||
|
$('gitea-create-title').value = '';
|
||||||
|
$('gitea-create-body').value = '';
|
||||||
|
setCreateStatus('');
|
||||||
|
createSelectedLabels.clear();
|
||||||
|
$('gitea-create-labels').innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first</div>';
|
||||||
|
|
||||||
|
// Try to populate the datalist autocomplete with the user's repos.
|
||||||
|
// If the token lacks read:repository scope this will 403 — the input is
|
||||||
|
// still usable manually, but we surface the error so the user knows why
|
||||||
|
// autocomplete isn't working and can update the token if they want.
|
||||||
|
const datalist = $('gitea-create-repo-list');
|
||||||
|
const hint = $('gitea-create-repo-hint');
|
||||||
|
const errBox = $('gitea-create-repo-error');
|
||||||
|
errBox.classList.add('hidden');
|
||||||
|
errBox.textContent = '';
|
||||||
|
|
||||||
|
if (cachedReposForAccount === currentAccountId && cachedRepos) {
|
||||||
|
renderRepoDatalist(cachedRepos);
|
||||||
|
// If the cached fetch had failed, also restore the error message
|
||||||
|
if (cachedReposError) {
|
||||||
|
errBox.textContent = cachedReposError;
|
||||||
|
errBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
datalist.innerHTML = '';
|
||||||
|
hint.textContent = '(loading suggestions...)';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/gitea/repos?account=${currentAccountId}`);
|
||||||
|
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;
|
||||||
|
renderRepoDatalist(cachedRepos);
|
||||||
|
} catch (err) {
|
||||||
|
hint.textContent = '(type owner/repo manually)';
|
||||||
|
cachedReposError = 'Network error loading repos: ' + err.message;
|
||||||
|
errBox.textContent = cachedReposError;
|
||||||
|
errBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRepoDatalist(repos) {
|
||||||
|
const datalist = $('gitea-create-repo-list');
|
||||||
|
const hint = $('gitea-create-repo-hint');
|
||||||
|
if (!repos.length) {
|
||||||
|
datalist.innerHTML = '';
|
||||||
|
hint.textContent = '(type owner/repo manually)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
datalist.innerHTML = repos.map(r =>
|
||||||
|
`<option value="${escapeHtml(r.full_name)}">${r.private ? ' (private)' : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
hint.textContent = `(${repos.length} suggestion${repos.length === 1 ? '' : 's'})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCreateRepoLabels() {
|
||||||
|
const input = $('gitea-create-repo');
|
||||||
|
const fullName = input.value.trim();
|
||||||
|
const labelsEl = $('gitea-create-labels');
|
||||||
|
createSelectedLabels.clear();
|
||||||
|
if (!fullName || !fullName.includes('/')) {
|
||||||
|
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">pick a repo first (owner/repo)</div>';
|
||||||
|
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 labels...</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger break-words">${escapeHtml(data.error || 'failed')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const labels = data.labels || [];
|
||||||
|
if (!labels.length) {
|
||||||
|
labelsEl.innerHTML = '<div class="text-[10px] text-t-dim italic">repo has no labels</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
labelsEl.innerHTML = labels.map(l => `
|
||||||
|
<label class="gitea-label-checkbox" data-id="${l.id}">
|
||||||
|
<input type="checkbox">
|
||||||
|
<span class="gitea-label" style="background:#${escapeHtml(l.color)};color:${labelTextColor(l.color)}">${escapeHtml(l.name)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
|
||||||
|
if (cb.checked) createSelectedLabels.add(id);
|
||||||
|
else createSelectedLabels.delete(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
labelsEl.innerHTML = `<div class="text-[10px] text-t-danger">${escapeHtml(err.message)}</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];
|
||||||
|
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');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -293,6 +1096,24 @@ function escapeHtml(s) {
|
|||||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 ───────────────────────────────────────
|
// ── Header button injection ───────────────────────────────────────
|
||||||
|
|
||||||
function injectHeaderButton() {
|
function injectHeaderButton() {
|
||||||
@@ -324,10 +1145,91 @@ function init() {
|
|||||||
|
|
||||||
$('gitea-account-select').addEventListener('change', e => {
|
$('gitea-account-select').addEventListener('change', e => {
|
||||||
currentAccountId = parseInt(e.target.value);
|
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-show-settings').addEventListener('click', gotoAccountsSettings);
|
||||||
$('gitea-welcome-configure').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);
|
||||||
|
// Auto-load labels after the user picks/types a repo (change fires on blur)
|
||||||
|
$('gitea-create-repo').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);
|
||||||
|
|
||||||
|
// 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();
|
injectHeaderButton();
|
||||||
|
|
||||||
// Register the "gitea" tab inside the core settings overlay
|
// Register the "gitea" tab inside the core settings overlay
|
||||||
|
|||||||
Reference in new issue
Block a user