terminal plugin, card edit history, bump script

Terminal: in-app command line with 22 commands (Shift+T toggle).
Navigation, CRUD, search, view toggles, tab completion, history.

Card history: card_history table tracks field changes on every PATCH.
GET /api/cards/{id}/history endpoint. Terminal `history` command.

bump.sh: standalone version bump for config.php, electron/package.json,
website footer. dist.sh no longer bumps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-13 14:10:43 +02:00
1 parent b9bd294c90
commit 2169c1de1b
11 files changed
+707 -1

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.3.7');
define('APP_VERSION', '1.4.0');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+22
View File
@@ -293,8 +293,30 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit;
}
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
$stmt = $db->prepare('SELECT field, old_value, new_value, changed_at FROM card_history WHERE card_id = :id ORDER BY changed_at DESC LIMIT 100');
$stmt->execute(['id' => $m[1]]);
echo json_encode($stmt->fetchAll()); exit;
}
if (preg_match('#^/api/cards/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
// Snapshot changed fields before update
$tracked = ['title', 'body', 'color', 'bucket_id', 'due_date'];
$changedTracked = array_intersect($tracked, array_keys($input));
if ($changedTracked) {
$old = $db->prepare('SELECT * FROM cards WHERE id = :id'); $old->execute(['id' => $m[1]]); $old = $old->fetch();
if ($old) {
$histStmt = $db->prepare('INSERT INTO card_history (card_id, field, old_value, new_value) VALUES (:cid, :f, :o, :n)');
foreach ($changedTracked as $f) {
$oldVal = $old[$f] ?? null; $newVal = $input[$f] ?? null;
if ($f === 'body') { $oldVal = mb_substr($oldVal ?? '', 0, 500); $newVal = mb_substr($newVal ?? '', 0, 500); }
if ((string)$oldVal !== (string)$newVal) {
$histStmt->execute(['cid' => $m[1], 'f' => $f, 'o' => $oldVal, 'n' => $newVal]);
}
}
}
}
$fields = []; $params = ['id' => $m[1]];
foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) {
if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
+48
View File
@@ -0,0 +1,48 @@
# terminal plugin
In-app command line for controlling hyprcrd with text commands.
## Toggle
**Shift+T** opens/closes the terminal. **ESC** closes it from inside.
## Commands
| Command | Description |
|---------|-------------|
| `help` | list all commands |
| `clear` | clear terminal output |
| `go <bucket>` | switch bucket (name or: all, current, favorites, journal, unsorted) |
| `open <card>` | open card preview (fuzzy title match) |
| `edit <card>` | open card in editor |
| `new <title>` | create card in active bucket |
| `delete <card>` | delete a card |
| `tag <card> <tag>` | append tag to card body |
| `untag <card> <tag>` | remove tag from card body |
| `color <card> <hex>` | change card color |
| `move <card> <bucket>` | move card to another bucket |
| `search <query>` | open search overlay with query |
| `find <text>` | list matching cards inline |
| `calendar` | open calendar |
| `assets` | open assets gallery |
| `export` | download export ZIP |
| `sort <mode>` | change sort (manual/newest/oldest/alpha/tag) |
| `split` | toggle split preview |
| `zen` | toggle zen mode |
| `count` | show card and bucket counts |
| `tags` | list all tags |
| `buckets` | list all buckets |
| `version` | show app version |
## Features
- **Tab completion** on command names
- **History** with arrow up/down
- **Fuzzy matching** on card and bucket names
- Colored output: accent for success, red for errors, grey for info
- Slides up from the bottom, max 45% viewport height
- Scrollable output buffer
## Disabling
Add `"terminal"` to the `disabled` array in `data/plugins.json`.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "terminal",
"version": "1.0.0",
"description": "In-app terminal for controlling hyprcrd with text commands. Toggle with Shift+T.",
"frontend": {
"js": ["terminal.js"],
"css": ["terminal.css"],
"html": ["terminal.html"]
}
}
+39
View File
@@ -0,0 +1,39 @@
.hterm {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 500;
background: var(--t-bg); border-top: 1px solid var(--t-border);
max-height: 45vh; display: flex; flex-direction: column;
box-shadow: 0 -8px 32px rgba(0,0,0,0.4);
transform: translateY(0);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.hterm.hidden { transform: translateY(100%); opacity: 0; pointer-events: none; }
#hterm-output {
flex: 1; overflow-y: auto; padding: 12px 16px;
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
font-size: 12px; line-height: 1.7;
scrollbar-width: thin;
}
.hterm-line { color: var(--t-text); white-space: pre-wrap; word-break: break-word; }
.hterm-line.cmd { color: var(--t-bright); }
.hterm-line.cmd::before { content: '> '; color: var(--t-accent); }
.hterm-line.ok { color: var(--t-accent); }
.hterm-line.err { color: var(--t-danger); }
.hterm-line.dim { color: var(--t-dim); }
.hterm-line.heading { color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 10px; margin-top: 8px; }
.hterm-input-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; border-top: 1px solid var(--t-border);
background: var(--t-surface);
}
.hterm-prompt {
color: var(--t-accent); font-family: var(--t-font-pixel, 'Silkscreen', cursive);
font-size: 11px; flex-shrink: 0;
}
#hterm-input {
flex: 1; background: transparent; border: none; color: var(--t-bright);
font-family: var(--t-font-content, 'JetBrains Mono', monospace);
font-size: 12px; outline: none;
}
#hterm-input::placeholder { color: var(--t-dim); }
+7
View File
@@ -0,0 +1,7 @@
<div id="hterm" class="hterm hidden">
<div id="hterm-output"></div>
<div class="hterm-input-row">
<span class="hterm-prompt">></span>
<input type="text" id="hterm-input" autocomplete="off" spellcheck="false" placeholder="type a command...">
</div>
</div>
+372
View File
@@ -0,0 +1,372 @@
// ── Terminal plugin — in-app command line ──
const h = window.hyprcrd;
const $ = id => document.getElementById(id);
const panel = $('hterm');
const output = $('hterm-output');
const input = $('hterm-input');
let visible = false;
let history = [];
let historyIdx = -1;
function toggle() {
visible = !visible;
panel.classList.toggle('hidden', !visible);
if (visible) { input.focus(); scrollBottom(); }
else { document.activeElement?.blur(); }
}
function scrollBottom() {
output.scrollTop = output.scrollHeight;
}
function print(text, cls = '') {
const line = document.createElement('div');
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
line.textContent = text;
output.appendChild(line);
scrollBottom();
}
function printHtml(html, cls = '') {
const line = document.createElement('div');
line.className = 'hterm-line' + (cls ? ' ' + cls : '');
line.innerHTML = html;
output.appendChild(line);
scrollBottom();
}
// ── API helpers ──
async function api(path, opts = {}) {
return h?.api?.(path, opts) || fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
}).then(r => r.json());
}
function fuzzy(needle, haystack) {
const n = needle.toLowerCase();
return haystack.filter(s => s.toLowerCase().includes(n));
}
function findCard(query) {
const q = query.toLowerCase();
return window.state?.cards?.find(c => c.title.toLowerCase() === q)
|| window.state?.cards?.find(c => c.title.toLowerCase().includes(q));
}
function findBucket(query) {
const q = query.toLowerCase();
return window.state?.buckets?.find(b => b.name.toLowerCase() === q)
|| window.state?.buckets?.find(b => b.name.toLowerCase().includes(q));
}
// ── Commands ──
const COMMANDS = {};
function register(name, desc, fn) {
COMMANDS[name] = { desc, fn };
}
register('help', 'show available commands', () => {
print('COMMANDS', 'heading');
const maxLen = Math.max(...Object.keys(COMMANDS).map(k => k.length));
for (const [name, cmd] of Object.entries(COMMANDS)) {
print(` ${name.padEnd(maxLen + 2)} ${cmd.desc}`, 'dim');
}
});
register('clear', 'clear terminal output', () => {
output.innerHTML = '';
});
register('go', 'go <bucket> — switch to bucket', (args) => {
const name = args.join(' ');
if (!name) { print('usage: go <bucket name>', 'err'); return; }
const specials = { all: 'all', current: 'current', favorites: 'favorites', journal: 'journal', unsorted: 'none' };
if (specials[name.toLowerCase()]) {
window.state.activeBucket = specials[name.toLowerCase()];
window._renderBoard?.();
print(`switched to ${name}`, 'ok');
return;
}
const b = findBucket(name);
if (!b) { print(`bucket not found: ${name}`, 'err'); return; }
window.state.activeBucket = b.id;
window._renderBoard?.();
print(`switched to ${b.name}`, 'ok');
});
register('open', 'open <card> — open card preview', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: open <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
window._openDetail?.(c.id);
print(`opened: ${c.title}`, 'ok');
});
register('edit', 'edit <card> — open card in editor', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: edit <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
window._openModal?.(c);
print(`editing: ${c.title}`, 'ok');
});
register('new', 'new <title> — create card in active bucket', async (args) => {
const title = args.join(' ');
if (!title) { print('usage: new <title>', 'err'); return; }
const bucketId = window.state?.activeBucket;
const numBucket = Number.isFinite(Number(bucketId)) ? Number(bucketId) : null;
const res = await api('api/cards', { method: 'POST', body: { title, body: '', color: '#38bdf8', bucket_id: numBucket } });
if (res?.id) {
print(`created: ${title} (id: ${res.id})`, 'ok');
await window._reloadAll?.();
} else {
print(res?.error || 'failed', 'err');
}
});
register('delete', 'delete <card> — delete a card', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: delete <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
await api(`api/cards/${c.id}`, { method: 'DELETE' });
print(`deleted: ${c.title}`, 'ok');
await window._reloadAll?.();
});
register('tag', 'tag <card> <tag> — add tag to card', async (args) => {
if (args.length < 2) { print('usage: tag <card title> <tag>', 'err'); return; }
const tag = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
const body = (c.body || '') + `\n#${tag}`;
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
print(`tagged ${c.title} with #${tag}`, 'ok');
await window._reloadAll?.();
});
register('untag', 'untag <card> <tag> — remove tag from card', async (args) => {
if (args.length < 2) { print('usage: untag <card title> <tag>', 'err'); return; }
const tag = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
const body = (c.body || '').replace(new RegExp(`\\s*#${tag}\\b`, 'g'), '');
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { body } });
print(`removed #${tag} from ${c.title}`, 'ok');
await window._reloadAll?.();
});
register('color', 'color <card> <hex> — change card color', async (args) => {
if (args.length < 2) { print('usage: color <card title> <#hex>', 'err'); return; }
const hex = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { color: hex } });
print(`${c.title} → ${hex}`, 'ok');
await window._reloadAll?.();
});
register('move', 'move <card> <bucket> — move card to bucket', async (args) => {
if (args.length < 2) { print('usage: move <card title> <bucket name>', 'err'); return; }
const bucketName = args.pop();
const q = args.join(' ');
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
const b = findBucket(bucketName);
if (!b) { print(`bucket not found: ${bucketName}`, 'err'); return; }
await api(`api/cards/${c.id}`, { method: 'PATCH', body: { bucket_id: b.id } });
print(`moved ${c.title} → ${b.name}`, 'ok');
await window._reloadAll?.();
});
register('search', 'search <query> — open search with query', (args) => {
const q = args.join(' ');
window._closeSearch?.();
const searchInput = document.getElementById('search-input');
const overlay = document.getElementById('search');
if (overlay) overlay.classList.add('open');
if (searchInput) { searchInput.value = q; searchInput.dispatchEvent(new Event('input')); searchInput.focus(); }
print(`searching: ${q}`, 'ok');
});
register('calendar', 'open calendar', () => { window._openCalendar?.(); print('calendar opened', 'ok'); });
register('assets', 'open assets gallery', () => { window._openGallery?.(); print('assets opened', 'ok'); });
register('export', 'download export ZIP', () => {
document.getElementById('export-btn')?.click();
print('export started', 'ok');
});
register('sort', 'sort <mode> — manual/newest/oldest/alpha/tag', (args) => {
const mode = args[0];
if (!mode) { print('usage: sort <manual|newest|oldest|alpha|tag>', 'err'); return; }
const sortBtn = document.getElementById('sort-toggle');
if (sortBtn) {
const label = document.getElementById('sort-label');
if (label) label.textContent = mode;
window.state.sortMode = mode;
window._renderBoard?.();
print(`sort: ${mode}`, 'ok');
}
});
register('split', 'toggle split preview', () => {
document.getElementById('board-split-toggle')?.click();
print('split toggled', 'ok');
});
register('zen', 'toggle zen mode on current detail', () => {
document.getElementById('detail-zen')?.click();
print('zen toggled', 'ok');
});
register('count', 'show card and bucket counts', () => {
const cards = window.state?.cards?.length || 0;
const buckets = window.state?.buckets?.length || 0;
print(`${cards} cards · ${buckets} buckets`, 'ok');
});
register('tags', 'list all tags', () => {
const tagSet = new Set();
for (const c of window.state?.cards || []) {
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
}
if (!tagSet.size) { print('no tags', 'dim'); return; }
const sorted = [...tagSet].sort();
print(`${sorted.length} tags:`, 'heading');
print(sorted.map(t => '#' + t).join(' '));
});
register('buckets', 'list all buckets', () => {
const bs = window.state?.buckets || [];
if (!bs.length) { print('no buckets', 'dim'); return; }
print(`${bs.length} buckets:`, 'heading');
for (const b of bs) print(` ${b.name} (${b.id})${b.parent_id ? ` → child of ${b.parent_id}` : ''}`);
});
register('history', 'history <card> — show edit history', async (args) => {
const q = args.join(' ');
if (!q) { print('usage: history <card title>', 'err'); return; }
const c = findCard(q);
if (!c) { print(`card not found: ${q}`, 'err'); return; }
let entries;
try {
const res = await fetch(`api/cards/${c.id}/history`);
entries = await res.json();
} catch (e) { print('failed to fetch history', 'err'); return; }
if (!entries || !entries.length) { print(`${c.title}: no edit history`, 'dim'); return; }
print(`${c.title} — ${entries.length} changes:`, 'heading');
for (const e of entries) {
const old = (e.old_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
const nw = (e.new_value || '(empty)').replace(/\n/g, '↵').substring(0, 60);
const time = e.changed_at?.substring(0, 16) || '?';
print(` ${time} ${e.field}: ${old} → ${nw}`, 'dim');
}
});
register('find', 'find <text> — list matching cards', (args) => {
const q = args.join(' ').toLowerCase();
if (!q) { print('usage: find <text>', 'err'); return; }
const hits = (window.state?.cards || []).filter(c =>
c.title.toLowerCase().includes(q) || (c.body || '').toLowerCase().includes(q) || (c.tags || '').toLowerCase().includes(q)
);
if (!hits.length) { print('no matches', 'dim'); return; }
print(`${hits.length} matches:`, 'heading');
for (const c of hits.slice(0, 20)) {
print(` ${c.title}${c.tags ? ' ' + (c.tags || '').split(',').map(t => '#' + t).join(' ') : ''}`, 'dim');
}
if (hits.length > 20) print(` ... and ${hits.length - 20} more`, 'dim');
});
register('version', 'show app version', () => {
const v = document.querySelector('[data-app-version]')?.textContent || '?';
print(v, 'ok');
});
// ── Tab completion ──
function tabComplete() {
const val = input.value;
const parts = val.split(/\s+/);
if (parts.length <= 1) {
const matches = fuzzy(parts[0] || '', Object.keys(COMMANDS));
if (matches.length === 1) { input.value = matches[0] + ' '; }
else if (matches.length > 1) { print(matches.join(' '), 'dim'); }
}
}
// ── Execute ──
async function exec(line) {
const trimmed = line.trim();
if (!trimmed) return;
history.push(trimmed);
historyIdx = history.length;
print(trimmed, 'cmd');
const parts = trimmed.split(/\s+/);
const cmd = parts.shift().toLowerCase();
const command = COMMANDS[cmd];
if (!command) {
print(`unknown command: ${cmd}. Type "help" for a list.`, 'err');
return;
}
try {
await command.fn(parts);
} catch (e) {
print(`error: ${e.message}`, 'err');
}
}
// ── Event handlers ──
input?.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
const val = input.value;
input.value = '';
exec(val);
} else if (e.key === 'Tab') {
e.preventDefault();
tabComplete();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIdx > 0) { historyIdx--; input.value = history[historyIdx]; }
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIdx < history.length - 1) { historyIdx++; input.value = history[historyIdx]; }
else { historyIdx = history.length; input.value = ''; }
} else if (e.key === 'Escape') {
e.preventDefault(); e.stopPropagation();
toggle();
}
});
// Shift+T toggle (capture, before shortcuts)
window.addEventListener('keydown', e => {
if (e.key === 'T' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
toggle();
}
}, { capture: true });
print('hyprcrd terminal — type "help" for commands, Shift+T to toggle', 'dim');
h?.registerPlugin({ name: 'terminal', version: '1.0.0' });
+1
View File
@@ -171,6 +171,7 @@ export function csrf() {
}
export async function closeAll() {
document.getElementById('hterm')?.classList.add('hidden');
for (const id of ['settings-overlay', 'help', 'shortcuts', 'search', 'calendar', 'gallery', 'detail', 'modal', 'tasks', 'dialog']) {
const el = document.getElementById(id);
if (el?.classList.contains('open')) el.classList.remove('open');
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, afterEach, expect, $, click, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
import { state } from '../../js/modules/state.js';
function termToggle() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'T', shiftKey: true, bubbles: true }));
}
function termExec(cmd) {
const input = document.getElementById('hterm-input');
if (!input) return;
input.value = cmd;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
}
function termOutput() {
return document.getElementById('hterm-output')?.textContent || '';
}
async function makeCard(title, body = '') {
return 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());
}
describe('Terminal: Toggle', () => {
afterEach(async () => {
document.getElementById('hterm')?.classList.add('hidden');
await closeAll();
});
it('panel exists but is hidden', () => {
const panel = document.getElementById('hterm');
expect(panel).toExist();
expect(panel.classList.contains('hidden')).toBe(true);
});
it('Shift+T opens the terminal', async () => {
termToggle();
await sleep(50);
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(false);
});
it('Shift+T again closes it', async () => {
termToggle();
await sleep(50);
termToggle();
await sleep(50);
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
});
it('ESC closes the terminal', async () => {
termToggle();
await sleep(50);
const input = document.getElementById('hterm-input');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(50);
expect(document.getElementById('hterm').classList.contains('hidden')).toBe(true);
});
});
describe('Terminal: Commands', () => {
afterEach(async () => {
document.getElementById('hterm')?.classList.add('hidden');
document.getElementById('hterm-output').innerHTML = '';
await closeAll();
});
it('help lists commands', async () => {
termToggle();
await sleep(50);
termExec('help');
await sleep(50);
const out = termOutput();
expect(out).toContain('help');
expect(out).toContain('new');
expect(out).toContain('search');
});
it('count shows numbers', async () => {
termToggle();
await sleep(50);
termExec('count');
await sleep(50);
expect(termOutput()).toContain('cards');
expect(termOutput()).toContain('buckets');
});
it('version shows version string', async () => {
termToggle();
await sleep(50);
termExec('version');
await sleep(50);
expect(termOutput()).toContain('v');
});
it('unknown command shows error', async () => {
termToggle();
await sleep(50);
termExec('xyznonexistent');
await sleep(50);
expect(termOutput()).toContain('unknown command');
});
it('tags lists existing tags', async () => {
await makeCard('__term_tag_test__', '#alpha #beta');
await window._reloadAll?.();
await sleep(200);
termToggle();
await sleep(50);
termExec('tags');
await sleep(50);
expect(termOutput()).toContain('#alpha');
expect(termOutput()).toContain('#beta');
});
it('find matches cards by title', async () => {
await makeCard('__term_find_me__', 'findable body');
await window._reloadAll?.();
await sleep(200);
termToggle();
await sleep(50);
termExec('find __term_find_me__');
await sleep(50);
expect(termOutput()).toContain('__term_find_me__');
});
it('go switches to bucket', async () => {
termToggle();
await sleep(50);
termExec('go all');
await sleep(100);
expect(String(state.activeBucket)).toBe('all');
});
it('new creates a card', async () => {
termToggle();
await sleep(50);
termExec('new __term_new_card__');
await sleep(400);
const out = termOutput();
expect(out).toContain('created');
const cards = await fetch('api/cards').then(r => r.json());
expect(cards.some(c => c.title === '__term_new_card__')).toBe(true);
});
it('clear empties output', async () => {
termToggle();
await sleep(50);
termExec('help');
await sleep(50);
expect(termOutput().length).toBeGreaterThan(10);
termExec('clear');
await sleep(50);
expect(document.getElementById('hterm-output').children.length).toBe(0);
});
});
describe('Terminal: Tab Completion', () => {
afterEach(async () => {
document.getElementById('hterm')?.classList.add('hidden');
document.getElementById('hterm-output').innerHTML = '';
await closeAll();
});
it('Tab completes unique command', async () => {
termToggle();
await sleep(50);
const input = document.getElementById('hterm-input');
input.value = 'vers';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
await sleep(50);
expect(input.value).toBe('version ');
});
it('Tab shows multiple matches', async () => {
termToggle();
await sleep(50);
const input = document.getElementById('hterm-input');
input.value = 'c';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
await sleep(50);
const out = termOutput();
expect(out).toContain('clear');
expect(out).toContain('count');
expect(out).toContain('calendar');
expect(out).toContain('color');
});
});
+1
View File
@@ -51,6 +51,7 @@ const FILES = [
'./notifications.test.js',
'./quick-capture.test.js',
'./asset-picker.test.js',
'./terminal.test.js',
'./theme.test.js',
];
+14
View File
@@ -151,5 +151,19 @@ class Database
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
}
// Card edit history
self::$pdo->exec('
CREATE TABLE IF NOT EXISTS card_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER NOT NULL,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
)
');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_card_history_card ON card_history(card_id, changed_at DESC)');
}
}