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
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',
];