hotkeys, gitea ticket-from-card, collab unread, runner auto-retry
board hotkeys (public/js/app.js):
* "c" toggles the calendar from the board (no modifier, body focus,
no full-screen overlay in front).
* "t" opens a tag quick-pick popover that lists every unique tag from
state.cards. Filter input + arrow-key/enter pick + ESC/click-outside
close. Picking sets state.activeTags = [tag] and re-renders the
board + tag bar. Toggle: a second "t" press closes the picker.
* "1"-"9" picks the Nth user bucket in the bar. The fixed filters
(all/current/favorites/journal/none) are skipped. Super-buckets
count as one slot and activate via the new activateBucket() helper
in buckets.js — that bypasses the click handler so the dropdown
popover does NOT open; the currently shown member (parent or
active child) is selected directly.
* ESC chain in the existing handler now also closes #calendar (was
missing — calendar.js only handles ESC for the day-popup, not the
whole overlay).
buckets (public/js/modules/buckets.js):
* New exported activateBucket(idOrSpecial) helper: setActiveBucket
+ clear activeGroup + renderBar + window._renderBoard. Used by
the number-key hotkey to select super-buckets without triggering
their dropdown.
board split panel (public/js/modules/board.js):
* closeBoardPreview() now removes the .active class from the
toolbar's #board-split-toggle. The previous code removed
.text-t-accent which was a no-op since toggleBoardSplit() uses
.active — the X button on the panel closed the split but left
the toolbar showing the toggle as still on.
email plugin (public/plugins/email/email.js):
* ESC closes the overlay reliably: the previous handler was on
#email-overlay, which only fires when focus is *inside* the
overlay (rare after open). Moved to a document-level keydown.
* Bare "m" toggles the mail overlay open/closed. Suppressed when
Ctrl/Meta/Alt is held, focus is in an input/textarea/contenteditable,
or any other full-screen overlay is in front.
collab-buckets (public/plugins/collab-buckets/backend.php):
* When a card change is applied in the pull phase from a foreign
author (insert / update / tombstone), bump
collab_buckets.updated_at to NOW for that bucket. has_unread is
computed as updated_at > last_seen_at and was previously never
triggered for incoming cards — the bucket-bar tab now gets the
unread dot + 3-pulse when the user is looking at a different
bucket. Own pushes are skipped (the relay echoes them back too).
gitea plugin: make ticket from card (public/plugins/gitea/gitea.js):
* New window._giteaMakeIssueFromCard(card) helper. Opens the gitea
overlay, switches to the create-issue form, fetches the card body
if it's lazy/null, and pre-fills #gitea-create-title and
#gitea-create-body. Does NOT submit — the user picks the repo and
reviews before clicking submit. Soft-bails on the welcome view
if no gitea account is configured yet.
context-menu (public/js/modules/context-menu.js):
* New "Make ticket" entry in the card right-click menu, only rendered
when window._giteaMakeIssueFromCard is exposed. Action handler
hides the menu and calls the helper. Sits next to "Copy text".
* joinSelectedCards() now dedupes hashtag mentions in the merged body
(first occurrence wins, case-insensitive) so a tag that appeared on
multiple source cards isn't repeated in the resulting body. Followed
by a tab/space/blank-line cleanup pass.
test runner auto-retry (public/plugins/tests/runner.js):
* runAll() takes an optional Set<string> of `${suite}::${test}` keys
and runs only those when set. Progress bar denominator reflects
the filtered count.
* run() collects every failed test after the first pass, restores the
backup so the retry runs against the same baseline state, then
re-runs only those via runAll(failedKeys). Retry results are merged
into the original results array with `retried: true` and the
original error preserved on `originalError`.
* renderResults distinguishes three states: passed first try (green ✓),
pass-on-retry (amber ⚠ "flaky — passed on retry" with the first-run
error shown), failed twice (red ✗ "failed twice"). Summary line
carries a "N flaky" count and goes amber if only flaky tests
appeared in the run.
tests (public/plugins/tests/context-menu.test.js):
* Two new tests for the make-ticket action:
- action exists in the menu when window._giteaMakeIssueFromCard
is exposed (soft-skip otherwise)
- clicking it opens the gitea overlay, surfaces the create-issue
form, and pre-fills title + body without submitting. Soft-skips
the assertion when no gitea account is configured (welcome view).
chore: bump version to 0.9.7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
285e3b4abe
commit
923de0cdd6
10 files changed
+436
-38
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.6');
|
||||
define('APP_VERSION', '0.9.7');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
+135
-1
@@ -6,7 +6,7 @@ import { api, setCsrf, getCsrf } from './modules/util.js';
|
||||
import { state, getGroupCards } from './modules/state.js';
|
||||
import { showDialog, isDialogOpen } from './modules/dialog.js';
|
||||
import './modules/theme.js';
|
||||
import { loadBuckets, renderTagBar } from './modules/buckets.js';
|
||||
import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js';
|
||||
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
|
||||
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
|
||||
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
|
||||
@@ -227,12 +227,56 @@ document.addEventListener('keydown', e => {
|
||||
else if (search.classList.contains('open')) closeSearch();
|
||||
else if (tasks?.classList.contains('open')) closeTasks();
|
||||
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
|
||||
else if (document.getElementById('calendar')?.classList.contains('open')) closeCalendar();
|
||||
return;
|
||||
}
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; }
|
||||
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
|
||||
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
|
||||
// Bare "c" toggles the calendar from the board. Same guards as "/":
|
||||
// no modifier, focus on <body> (so it doesn't intercept text typing),
|
||||
// and no full-screen overlay already in front.
|
||||
if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
|
||||
const calendar = document.getElementById('calendar');
|
||||
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay')];
|
||||
if (blockers.some(el => el?.classList.contains('open'))) return;
|
||||
e.preventDefault();
|
||||
if (calendar?.classList.contains('open')) closeCalendar();
|
||||
else openCalendar();
|
||||
return;
|
||||
}
|
||||
// Bare "t" opens the tag quick-pick on the board. Same focus +
|
||||
// overlay guards as "c". Picking a tag sets state.activeTags = [tag].
|
||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
|
||||
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
|
||||
if (blockers.some(el => el?.classList.contains('open'))) return;
|
||||
e.preventDefault();
|
||||
openTagQuickPick();
|
||||
return;
|
||||
}
|
||||
// Bare "1"–"9" picks the Nth user bucket in the bar — fixed filters
|
||||
// (all/current/favorites/journal/none) are skipped, super-buckets
|
||||
// count as one slot and activate via activateBucket() so the
|
||||
// dropdown popover is NOT triggered.
|
||||
if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
|
||||
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
|
||||
if (blockers.some(el => el?.classList.contains('open'))) return;
|
||||
const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
||||
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]
|
||||
.filter(t => !SPECIAL.has(t.dataset.bucket));
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
const tile = tiles[idx];
|
||||
if (tile) {
|
||||
e.preventDefault();
|
||||
// Super-bucket: activate the currently shown member (parent or
|
||||
// active child) directly instead of clicking, which would open
|
||||
// the dropdown popover. Regular bucket: just take its data-bucket.
|
||||
const target = tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket;
|
||||
activateBucket(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys navigate group in detail view
|
||||
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
|
||||
@@ -288,6 +332,96 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
||||
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500);
|
||||
});
|
||||
|
||||
// ── Tag quick-pick (bare "t" hotkey on the board) ──
|
||||
|
||||
/** Show a small popover listing every tag found on any card. Typing
|
||||
* filters; arrow keys + enter pick; clicking outside or ESC closes.
|
||||
* Picking a tag sets state.activeTags = [tag] and re-renders the board. */
|
||||
function openTagQuickPick() {
|
||||
const existing = document.getElementById('tag-quickpick');
|
||||
if (existing) { existing.remove(); return; }
|
||||
|
||||
const tagSet = new Set();
|
||||
for (const c of state.cards) {
|
||||
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
|
||||
}
|
||||
const allTags = [...tagSet].sort();
|
||||
if (!allTags.length) return;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'tag-quickpick';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:80px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:380px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden;';
|
||||
box.innerHTML = `
|
||||
<div style="padding:10px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
|
||||
<i class="ph ph-funnel text-t-accent"></i>
|
||||
<input id="tag-quickpick-input" type="text" placeholder="filter tags…"
|
||||
style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:6px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
|
||||
</div>
|
||||
<div id="tag-quickpick-list" style="flex:1;overflow-y:auto;padding:4px 0;"></div>
|
||||
`;
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = box.querySelector('#tag-quickpick-input');
|
||||
const list = box.querySelector('#tag-quickpick-list');
|
||||
let filtered = allTags;
|
||||
let active = 0;
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
function renderList() {
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = '<div style="padding:14px 12px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no tags match</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = filtered.map((t, i) => `
|
||||
<button class="tag-quickpick-row" data-tag="${escHtml(t)}" data-idx="${i}"
|
||||
style="display:block;width:100%;text-align:left;padding:6px 14px;font-size:11px;font-family:var(--t-font-pixel,'Silkscreen',cursive);color:${i === active ? 'var(--t-bright)' : 'var(--t-text)'};background:${i === active ? 'var(--t-raised)' : 'transparent'};border:none;cursor:pointer;">
|
||||
#${escHtml(t)}
|
||||
</button>
|
||||
`).join('');
|
||||
list.querySelectorAll('[data-tag]').forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => { active = parseInt(btn.dataset.idx, 10); renderList(); });
|
||||
btn.addEventListener('click', () => pick(btn.dataset.tag));
|
||||
});
|
||||
const activeEl = list.querySelector(`[data-idx="${active}"]`);
|
||||
activeEl?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function pick(tag) {
|
||||
state.activeTags = [tag];
|
||||
close();
|
||||
window._renderTagBar?.();
|
||||
window._renderBoard?.();
|
||||
}
|
||||
function close() {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1); renderList(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); renderList(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[active]) pick(filtered[active]); }
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const f = input.value.toLowerCase().replace(/^#/, '');
|
||||
filtered = f ? allTags.filter(t => t.toLowerCase().includes(f)) : allTags;
|
||||
active = 0;
|
||||
renderList();
|
||||
});
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||
// Capture-phase keydown so ESC etc. don't bubble back to the rest of
|
||||
// the page handlers (search, board) while the picker is open.
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
|
||||
renderList();
|
||||
setTimeout(() => input.focus(), 0);
|
||||
}
|
||||
|
||||
// ── Boot ──
|
||||
|
||||
checkAuth();
|
||||
|
||||
@@ -312,8 +312,11 @@ function closeBoardPreview() {
|
||||
boardPreview?.classList.add('hidden');
|
||||
state.boardSplitOpen = false;
|
||||
localStorage.setItem('hyprcrd-board-split', '0');
|
||||
// The toolbar's active state lives on the `.active` class — the
|
||||
// previous `text-t-accent` removal was a no-op since that class
|
||||
// isn't what toggleBoardSplit uses.
|
||||
const btn = $('board-split-toggle');
|
||||
if (btn) btn.classList.remove('text-t-accent');
|
||||
if (btn) btn.classList.remove('active');
|
||||
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
|
||||
state.boardPreviewCardId = null;
|
||||
resetBoardPreview();
|
||||
|
||||
@@ -186,6 +186,18 @@ const inpBucket = $('inp-bucket');
|
||||
const searchFilter = $('search-bucket-filter');
|
||||
const tasksFilter = $('tasks-bucket-filter');
|
||||
|
||||
/** Switch the active bucket without going through the click path —
|
||||
* used by the number-key hotkeys (and any other code that wants to
|
||||
* activate a bucket programmatically without triggering the super-
|
||||
* bucket dropdown). Persists via setActiveBucket and re-renders the
|
||||
* bar + board so the active highlight tracks. */
|
||||
export function activateBucket(idOrSpecial) {
|
||||
setActiveBucket(idOrSpecial);
|
||||
state.activeGroup = null;
|
||||
renderBar();
|
||||
window._renderBoard?.();
|
||||
}
|
||||
|
||||
export async function loadBuckets() {
|
||||
state.buckets = await api('api/buckets');
|
||||
// The persisted activeBucket may point to a bucket that no longer
|
||||
|
||||
@@ -74,7 +74,21 @@ export async function joinSelectedCards() {
|
||||
const ordered = [winner, ...losers];
|
||||
const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n');
|
||||
const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean);
|
||||
const mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
|
||||
let mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
|
||||
// Dedupe hashtag mentions across the merged body — first occurrence
|
||||
// wins, subsequent ones get stripped (case-insensitive). Without
|
||||
// this the merged card has every shared tag listed once per source
|
||||
// card, which clutters the body and makes the rendered tag chips
|
||||
// look duplicated to a casual reader (the server's extractTags also
|
||||
// dedupes the tags column, but the body text itself is what the
|
||||
// user actually edits).
|
||||
const seenTags = new Set();
|
||||
mergedBody = mergedBody.replace(/(^|\s)#([a-zA-Z0-9_/-]+)/g, (match, lead, tag) => {
|
||||
const key = tag.toLowerCase();
|
||||
if (seenTags.has(key)) return lead; // strip the dupe, keep the leading whitespace
|
||||
seenTags.add(key);
|
||||
return match;
|
||||
}).replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Snapshot for undo BEFORE we mutate anything
|
||||
const winnerPrev = { title: winner.title, body: winner.body || '' };
|
||||
@@ -123,6 +137,7 @@ export function showContextMenu(x, y, card) {
|
||||
<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm ${parseTags(card).includes('done') ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${parseTags(card).includes('done') ? 'Unmark done' : 'Mark done'}</span></button>
|
||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">Save .md</span></button>
|
||||
<button class="ctx-item" data-action="copy-text"><i class="ph ph-clipboard-text text-sm"></i><span class="font-pixel text-[10px]">Copy text</span></button>
|
||||
${typeof window._giteaMakeIssueFromCard === 'function' ? `<button class="ctx-item" data-action="make-ticket"><i class="ph ph-git-pull-request text-sm"></i><span class="font-pixel text-[10px]">Make ticket</span></button>` : ''}
|
||||
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">Push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'Unfavorite' : 'Favorite'}</span></button>
|
||||
<div class="ctx-sep"></div>
|
||||
@@ -306,6 +321,12 @@ export function showContextMenu(x, y, card) {
|
||||
await window._reloadAll();
|
||||
}
|
||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||
else if (a === 'make-ticket') {
|
||||
hideContextMenu();
|
||||
// Gitea plugin pre-fills the create-issue form from this
|
||||
// card and leaves it open — the user reviews and submits.
|
||||
await window._giteaMakeIssueFromCard?.(card);
|
||||
}
|
||||
else if (a === 'copy-text') {
|
||||
hideContextMenu();
|
||||
let body = card.body || '';
|
||||
|
||||
@@ -506,6 +506,13 @@ function collabSyncCards(PDO $db): array {
|
||||
// the delete back to the relay we snapshot the highest tombstone id
|
||||
// before apply and purge everything > that marker afterwards.
|
||||
$tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn();
|
||||
// Buckets that received changes from a foreign author. Used at the end
|
||||
// of the apply phase to bump collab_buckets.updated_at, which the
|
||||
// frontend's has_unread calculation reads — that's how a different
|
||||
// bucket-bar tab gets the unread dot + 3-pulse when a peer pushes a
|
||||
// card while the user is looking at something else.
|
||||
$selfUser = collabRelaySettings()['user'];
|
||||
$unreadBuckets = [];
|
||||
try {
|
||||
foreach ($incoming as $c) {
|
||||
// Resolve local bucket id
|
||||
@@ -514,6 +521,8 @@ function collabSyncCards(PDO $db): array {
|
||||
$localBucketId = $bid->fetchColumn();
|
||||
if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen
|
||||
|
||||
$foreignAuthor = ($c['author_username'] ?? '') !== $selfUser;
|
||||
|
||||
// Find local card by relay_uid — grab all fields so we can
|
||||
// stash a conflict copy if needed before overwriting
|
||||
$find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u');
|
||||
@@ -525,6 +534,7 @@ function collabSyncCards(PDO $db): array {
|
||||
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]);
|
||||
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]);
|
||||
$stats['deleted_local']++;
|
||||
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -609,8 +619,21 @@ function collabSyncCards(PDO $db): array {
|
||||
'r' => $c['updated_at'],
|
||||
]);
|
||||
$stats['pulled']++;
|
||||
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||
}
|
||||
} finally {
|
||||
// Bump `collab_buckets.updated_at` for every bucket that received
|
||||
// changes from another user. The frontend's has_unread is
|
||||
// `updated_at > last_seen_at`, so this is what triggers the unread
|
||||
// dot + 3-pulse on the bucket-bar tab when the user is looking at
|
||||
// something else. Skipped for the user's own pushes since the
|
||||
// relay echoes those back too.
|
||||
if ($unreadBuckets) {
|
||||
$bumpStmt = $db->prepare("UPDATE collab_buckets SET updated_at = datetime('now') WHERE uid = :u");
|
||||
foreach (array_keys($unreadBuckets) as $u) {
|
||||
$bumpStmt->execute(['u' => $u]);
|
||||
}
|
||||
}
|
||||
// Purge any tombstones the apply phase itself created. Tombstones
|
||||
// queued before apply (by actual user deletes) survive and get
|
||||
// drained on the next poll iteration.
|
||||
|
||||
@@ -877,8 +877,42 @@ function init() {
|
||||
$('email-overlay').addEventListener('click', e => {
|
||||
if (e.target === $('email-overlay')) closeEmail();
|
||||
});
|
||||
$('email-overlay').addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { closeEmail(); e.stopPropagation(); }
|
||||
// Document-level Escape: works regardless of which element is focused
|
||||
// (the previous overlay-level keydown only fired when focus was inside
|
||||
// the overlay, which is rarely the case after open).
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!$('email-overlay')?.classList.contains('open')) return;
|
||||
// Don't steal Escape from a sub-modal that lives over the overlay
|
||||
const dialog = document.getElementById('dialog');
|
||||
if (dialog?.classList.contains('open')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeEmail();
|
||||
});
|
||||
// Document-level "m" toggles the mail overlay. Suppressed when the
|
||||
// user is typing into an input / textarea / contenteditable so it
|
||||
// doesn't intercept normal text entry. Also suppressed if any modal
|
||||
// overlay is open (editor, search, settings, …) so we don't fight
|
||||
// those for the hotkey.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'm' && e.key !== 'M') return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const ae = document.activeElement;
|
||||
if (ae) {
|
||||
if (ae.isContentEditable) return;
|
||||
const tag = ae.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
}
|
||||
// Don't toggle while another full-screen overlay is in front
|
||||
const blockers = ['modal', 'detail', 'search', 'settings-overlay', 'gallery', 'help', 'calendar', 'tasks', 'dialog'];
|
||||
for (const id of blockers) {
|
||||
if (document.getElementById(id)?.classList.contains('open')) return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const overlay = $('email-overlay');
|
||||
if (overlay?.classList.contains('open')) closeEmail();
|
||||
else openEmail();
|
||||
});
|
||||
|
||||
$('email-account-select').addEventListener('change', e => {
|
||||
|
||||
@@ -1593,6 +1593,35 @@ if (document.readyState === 'loading') {
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the gitea overlay, switch to the create-issue form and pre-fill
|
||||
* title + body from a hyprcrd card. Does NOT submit — the user reviews
|
||||
* and clicks Submit themselves. Called from the card right-click menu's
|
||||
* "Make ticket" action (see public/js/modules/context-menu.js).
|
||||
*/
|
||||
window._giteaMakeIssueFromCard = async function (card) {
|
||||
if (!card) return;
|
||||
// Body might not be loaded in state.cards (lazy field) — fetch it.
|
||||
let body = card.body;
|
||||
if (body == null) {
|
||||
try {
|
||||
const all = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
|
||||
const fresh = all.find(c => c.id == card.id);
|
||||
body = fresh?.body || '';
|
||||
} catch { body = ''; }
|
||||
}
|
||||
await openGitea();
|
||||
if (accounts.length === 0) return; // welcome view — user must configure first
|
||||
await openCreateForm();
|
||||
const titleInput = $('gitea-create-title');
|
||||
const bodyInput = $('gitea-create-body');
|
||||
if (titleInput) titleInput.value = card.title || '';
|
||||
if (bodyInput) bodyInput.value = body || '';
|
||||
// Cursor on the repo field so the user can pick the destination
|
||||
// first. Title + body are already filled.
|
||||
$('gitea-create-repo')?.focus();
|
||||
};
|
||||
|
||||
hyprcrd.registerPlugin({
|
||||
name: 'gitea',
|
||||
version: '0.1.0',
|
||||
|
||||
@@ -130,3 +130,86 @@ describe('Context Menu', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card: Make ticket (gitea)', () => {
|
||||
afterEach(async () => {
|
||||
// Always close the gitea overlay so subsequent tests aren't
|
||||
// running on top of it
|
||||
document.getElementById('gitea-overlay')?.classList.remove('open');
|
||||
await sleep(50);
|
||||
});
|
||||
|
||||
it('action is exposed when the gitea plugin is loaded', () => {
|
||||
// The make-ticket entry only renders when the plugin exposes
|
||||
// _giteaMakeIssueFromCard. If the plugin isn't loaded in this
|
||||
// build, treat as a soft skip — nothing to test.
|
||||
if (typeof window._giteaMakeIssueFromCard !== 'function') return;
|
||||
const card = $('#board [data-id]');
|
||||
if (!card) return;
|
||||
rightClick(card);
|
||||
// Use sleep here, the menu render is synchronous after the dispatch
|
||||
const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action);
|
||||
expect(actions).toContain('make-ticket');
|
||||
});
|
||||
|
||||
it('clicking make-ticket pre-fills the gitea create form (no submit)', async () => {
|
||||
if (typeof window._giteaMakeIssueFromCard !== 'function') return;
|
||||
|
||||
const title = '__test_make_ticket_title__';
|
||||
const body = 'a body that should land in the gitea form';
|
||||
const res = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
||||
if (!cardEl) return;
|
||||
rightClick(cardEl);
|
||||
await sleep(150);
|
||||
const btn = $$('#ctx-items [data-action="make-ticket"]')[0];
|
||||
expect(btn).toExist();
|
||||
click(btn);
|
||||
|
||||
// openGitea + openCreateForm involve account fetch + repo fetch.
|
||||
// Wait until the gitea overlay is open OR a reasonable timeout
|
||||
// passes. Without a configured gitea account the welcome view
|
||||
// shows instead — that's a soft skip since we can't test the
|
||||
// pre-fill in that case.
|
||||
let overlay = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
overlay = document.getElementById('gitea-overlay');
|
||||
if (overlay?.classList.contains('open')) break;
|
||||
await sleep(50);
|
||||
}
|
||||
if (!overlay?.classList.contains('open')) return;
|
||||
|
||||
const welcome = document.getElementById('gitea-view-welcome');
|
||||
if (welcome && !welcome.classList.contains('hidden')) {
|
||||
// No gitea accounts configured — function returns early,
|
||||
// form never renders, can't test the pre-fill path.
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until openCreateForm has finished its async repo load
|
||||
// and surfaced the form (it's hidden by default).
|
||||
let form = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
form = document.getElementById('gitea-create-form');
|
||||
if (form && !form.classList.contains('hidden')) break;
|
||||
await sleep(50);
|
||||
}
|
||||
expect(form && !form.classList.contains('hidden')).toBe(true);
|
||||
|
||||
// The form must NOT auto-submit — the title and body should be
|
||||
// populated and the form still visible afterwards.
|
||||
const titleInput = document.getElementById('gitea-create-title');
|
||||
const bodyInput = document.getElementById('gitea-create-body');
|
||||
expect(titleInput?.value).toBe(title);
|
||||
expect(bodyInput?.value).toBe(body);
|
||||
// Issues list / detail view should NOT be the foreground
|
||||
const detail = document.getElementById('gitea-detail');
|
||||
expect(detail?.classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -241,38 +241,48 @@ function hideProgress() {
|
||||
document.body.style.height = '';
|
||||
}
|
||||
|
||||
async function runAll() {
|
||||
const results = [];
|
||||
const total = suites.reduce((n, s) => n + s.tests.length, 0);
|
||||
// Initial label before any test has started
|
||||
updateProgress(0, total, 'starting…', false);
|
||||
/**
|
||||
* Run tests. With `only=null` runs everything; with `only` set to a
|
||||
* Set<string> of `${suite.name}::${test.name}` keys, runs just those.
|
||||
* The progress bar denominator reflects the filtered count so the
|
||||
* fill bar makes sense for both full runs and retries.
|
||||
*/
|
||||
async function runAll(only = null) {
|
||||
const allTests = [];
|
||||
for (const suite of suites) {
|
||||
for (const test of suite.tests) {
|
||||
if (!only || only.has(`${suite.name}::${test.name}`)) {
|
||||
allTests.push({ suite, test });
|
||||
}
|
||||
}
|
||||
}
|
||||
const total = allTests.length;
|
||||
updateProgress(0, total, only ? 'retrying…' : 'starting…', false);
|
||||
let done = 0;
|
||||
let anyFailed = false;
|
||||
for (const suite of suites) {
|
||||
const suiteResult = { name: suite.name, tests: [] };
|
||||
for (const test of suite.tests) {
|
||||
done++;
|
||||
updateProgress(done, total, `${suite.name} — ${test.name}`, anyFailed);
|
||||
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
if (suite.beforeEach) await suite.beforeEach();
|
||||
await test.fn();
|
||||
if (suite.afterEach) await suite.afterEach();
|
||||
} catch (err) {
|
||||
entry.status = 'fail';
|
||||
entry.error = err.message;
|
||||
anyFailed = true;
|
||||
// Always run afterEach on failure too
|
||||
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
||||
}
|
||||
entry.time = Math.round(performance.now() - t0);
|
||||
suiteResult.tests.push(entry);
|
||||
const suiteMap = new Map();
|
||||
for (const { suite, test } of allTests) {
|
||||
done++;
|
||||
updateProgress(done, total, `${suite.name} — ${test.name}`, anyFailed);
|
||||
const entry = { name: test.name, status: 'pass', error: null, time: 0 };
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
if (suite.beforeEach) await suite.beforeEach();
|
||||
await test.fn();
|
||||
if (suite.afterEach) await suite.afterEach();
|
||||
} catch (err) {
|
||||
entry.status = 'fail';
|
||||
entry.error = err.message;
|
||||
anyFailed = true;
|
||||
// Always run afterEach on failure too
|
||||
try { if (suite.afterEach) await suite.afterEach(); } catch {}
|
||||
}
|
||||
results.push(suiteResult);
|
||||
entry.time = Math.round(performance.now() - t0);
|
||||
if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] });
|
||||
suiteMap.get(suite.name).tests.push(entry);
|
||||
}
|
||||
hideProgress();
|
||||
return results;
|
||||
return [...suiteMap.values()];
|
||||
}
|
||||
|
||||
// ── UI Panel ──
|
||||
@@ -313,7 +323,7 @@ function createPanel() {
|
||||
function renderResults(results) {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
let total = 0, passed = 0, failed = 0, totalTime = 0;
|
||||
let total = 0, passed = 0, failed = 0, flaky = 0, totalTime = 0;
|
||||
|
||||
let html = '';
|
||||
for (const suite of results) {
|
||||
@@ -322,20 +332,33 @@ function renderResults(results) {
|
||||
for (const t of suite.tests) {
|
||||
total++;
|
||||
totalTime += t.time;
|
||||
if (t.status === 'pass') {
|
||||
if (t.status === 'pass' && t.retried) {
|
||||
// Failed first time, passed on auto-retry → flaky.
|
||||
// Counts as passed in the summary but coloured amber +
|
||||
// shows the original error so the user can decide whether
|
||||
// to investigate or move on.
|
||||
passed++;
|
||||
flaky++;
|
||||
html += `<div style="color:#fbbf24;padding-left:16px;">⚠ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span> <span style="color:#fbbf24;font-style:italic;">(flaky — passed on retry)</span></div>`;
|
||||
if (t.originalError) {
|
||||
html += `<div style="color:#fbbf24;padding-left:32px;font-size:11px;opacity:0.7;">first run: ${esc(t.originalError)}</div>`;
|
||||
}
|
||||
} else if (t.status === 'pass') {
|
||||
passed++;
|
||||
html += `<div style="color:#4ade80;padding-left:16px;">✓ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||
} else {
|
||||
failed++;
|
||||
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span></div>`;
|
||||
const retryNote = t.retried ? ' <span style="color:#fb7185;font-style:italic;">(failed twice)</span>' : '';
|
||||
html += `<div style="color:#fb7185;padding-left:16px;">✗ ${esc(t.name)} <span style="color:#475569;">${t.time}ms</span>${retryNote}</div>`;
|
||||
html += `<div style="color:#fb7185;padding-left:32px;font-size:11px;opacity:0.8;">${esc(t.error)}</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
output.innerHTML = html;
|
||||
summary.textContent = `${passed}/${total} passed, ${failed} failed — ${totalTime}ms`;
|
||||
summary.style.color = failed ? '#fb7185' : '#4ade80';
|
||||
const flakyNote = flaky ? `, ${flaky} flaky` : '';
|
||||
summary.textContent = `${passed}/${total} passed, ${failed} failed${flakyNote} — ${totalTime}ms`;
|
||||
summary.style.color = failed ? '#fb7185' : (flaky ? '#fbbf24' : '#4ade80');
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/</g, '<').replace(/>/g, '>'); }
|
||||
@@ -366,6 +389,42 @@ async function run() {
|
||||
// 4. Run tests
|
||||
const results = await runAll();
|
||||
|
||||
// 4b. Auto-retry: collect every failed (suite, test) pair, restore the
|
||||
// backup so the retry runs against the same baseline state the first
|
||||
// pass started from, then re-run *only* those tests. Pass-on-retry is
|
||||
// marked so the user can tell flaky from genuinely broken at a glance.
|
||||
const failedKeys = new Set();
|
||||
for (const sr of results) {
|
||||
for (const t of sr.tests) {
|
||||
if (t.status === 'fail') failedKeys.add(`${sr.name}::${t.name}`);
|
||||
}
|
||||
}
|
||||
if (failedKeys.size) {
|
||||
await closeAll();
|
||||
try {
|
||||
await restoreBackup();
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
} catch {}
|
||||
const retryResults = await runAll(failedKeys);
|
||||
const retryMap = new Map();
|
||||
for (const sr of retryResults) {
|
||||
for (const t of sr.tests) {
|
||||
retryMap.set(`${sr.name}::${t.name}`, t);
|
||||
}
|
||||
}
|
||||
for (const sr of results) {
|
||||
for (let i = 0; i < sr.tests.length; i++) {
|
||||
const key = `${sr.name}::${sr.tests[i].name}`;
|
||||
const retried = retryMap.get(key);
|
||||
if (retried) {
|
||||
const originalError = sr.tests[i].error;
|
||||
sr.tests[i] = { ...retried, retried: true, originalError };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clean up any leftover UI state
|
||||
await closeAll();
|
||||
|
||||
|
||||
Reference in new issue
Block a user