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:
1 parent
b9bd294c90
commit
2169c1de1b
11 files changed
+707
-1
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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]; }
|
||||
|
||||
@@ -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`.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
@@ -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' });
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ const FILES = [
|
||||
'./notifications.test.js',
|
||||
'./quick-capture.test.js',
|
||||
'./asset-picker.test.js',
|
||||
'./terminal.test.js',
|
||||
'./theme.test.js',
|
||||
];
|
||||
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user