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:
thandClaude Opus 4.6 committed 2026-04-11 23:31:05 +02:00
1 parent 285e3b4abe
commit 923de0cdd6
10 files changed
+436 -38

No files matched your search

+23
View File
@@ -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.
+36 -2
View File
@@ -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 => {
+29
View File
@@ -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',
+83
View File
@@ -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);
});
});
+91 -32
View File
@@ -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, '&lt;').replace(/>/g, '&gt;'); }
@@ -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();