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

+135 -1
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
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();
+4 -1
View File
@@ -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();
+12
View File
@@ -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
+22 -1
View File
@@ -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 || '';