Merge feature/polish: Vim-Navigation, SQL-Autocomplete, Import/Export, erweiterte Settings

Konflikte in main.ts und styles.css aufgeloest — beide Feature-Branches integriert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:57:17 +01:00
commit 878b144ccc
6 changed files with 860 additions and 7 deletions

View file

@ -22,6 +22,8 @@ import { FavoritesManager, SaveFavoriteModal } from './management/favorites';
import { TemplateManager, TemplatePickerModal } from './management/templates';
import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view';
import { ProjectionEngine, ProjectionPickerModal } from './projection/projection-engine';
import { KeyboardNavigator } from './viz/keyboard-nav';
import { SqlAutocomplete } from './query/autocomplete';
export default class LogfirePlugin extends Plugin {
settings!: LogfireSettings;
@ -33,6 +35,7 @@ export default class LogfirePlugin extends Plugin {
favoritesManager!: FavoritesManager;
templateManager!: TemplateManager;
projectionEngine!: ProjectionEngine;
autocomplete!: SqlAutocomplete;
private fileCollector!: FileCollector;
private contentCollector!: ContentCollector;
@ -41,6 +44,7 @@ export default class LogfirePlugin extends Plugin {
private systemCollector!: SystemCollector;
private statusBar!: StatusBar;
private virtualTables!: VirtualTableManager;
private keyboardNav!: KeyboardNavigator;
private paused = false;
@ -170,6 +174,12 @@ export default class LogfirePlugin extends Plugin {
// Projection Engine
this.projectionEngine = new ProjectionEngine(this.app, this.db, this.eventBus, this.settings);
this.projectionEngine.start();
// Autocomplete (nach Virtual Tables)
this.autocomplete = new SqlAutocomplete(this.db);
// Keyboard Navigator
this.keyboardNav = new KeyboardNavigator(this.app);
});
console.log('[Logfire] Plugin geladen. Session:', this.sessionManager.currentSessionId);
@ -346,7 +356,7 @@ export default class LogfirePlugin extends Plugin {
id: 'open-query',
name: 'Query-Editor \u00f6ffnen',
callback: () => {
new QueryModal(this.app, this.db, this.historyManager).open();
new QueryModal(this.app, this.db, this.historyManager, undefined, this.autocomplete).open();
},
});
@ -361,7 +371,7 @@ export default class LogfirePlugin extends Plugin {
name: 'Query-Templates anzeigen',
callback: () => {
new TemplatePickerModal(this, this.templateManager, (sql) => {
new QueryModal(this.app, this.db, this.historyManager, sql).open();
new QueryModal(this.app, this.db, this.historyManager, sql, this.autocomplete).open();
}).open();
},
});
@ -405,6 +415,40 @@ export default class LogfirePlugin extends Plugin {
this.projectionEngine.runAllProjections();
},
});
this.addCommand({
id: 'export-csv',
name: 'Letzte Query als CSV exportieren',
callback: () => {
new Notice('CSV-Export: Bitte Query-Editor öffnen und dort exportieren.');
},
});
this.addCommand({
id: 'export-json',
name: 'Letzte Query als JSON exportieren',
callback: () => {
new Notice('JSON-Export: Bitte Query-Editor öffnen und dort exportieren.');
},
});
this.addCommand({
id: 'toggle-vim-nav',
name: 'Vim-Navigation umschalten',
callback: () => {
if (this.keyboardNav?.isActive()) {
this.keyboardNav.detach();
new Notice('Logfire: Vim-Navigation deaktiviert.');
} else {
const active = document.querySelector('.logfire-qm-results, .logfire-dash-widget-content');
if (active instanceof HTMLElement && this.keyboardNav?.attach(active)) {
new Notice('Logfire: Vim-Navigation aktiviert (j/k/h/l, gg/G, /, y, Enter, Esc).');
} else {
new Notice('Logfire: Keine Tabelle gefunden.');
}
}
},
});
}
// ---------------------------------------------------------------------------

228
src/query/autocomplete.ts Normal file
View file

@ -0,0 +1,228 @@
import { DatabaseManager } from '../core/database';
export interface Suggestion {
text: string;
type: 'table' | 'column' | 'keyword' | 'function' | 'event-type' | 'category';
detail?: string;
}
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN',
'ORDER BY', 'GROUP BY', 'HAVING', 'LIMIT', 'OFFSET', 'AS', 'ON', 'JOIN',
'LEFT JOIN', 'INNER JOIN', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'IS', 'NULL', 'ASC', 'DESC',
'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'WITH', 'UNION', 'EXCEPT', 'INTERSECT',
];
const SQL_FUNCTIONS = [
'COUNT(', 'SUM(', 'AVG(', 'MIN(', 'MAX(',
'COALESCE(', 'IFNULL(', 'NULLIF(',
'LENGTH(', 'LOWER(', 'UPPER(', 'TRIM(', 'SUBSTR(',
'DATE(', 'TIME(', 'DATETIME(', 'STRFTIME(',
'JSON_EXTRACT(', 'JSON_ARRAY_LENGTH(',
'TYPEOF(', 'CAST(', 'ABS(', 'ROUND(',
'REPLACE(', 'INSTR(', 'PRINTF(',
'GROUP_CONCAT(', 'TOTAL(',
];
const EVENT_TYPES = [
'file:create', 'file:delete', 'file:rename', 'file:move', 'file:modify',
'content:words-changed', 'content:link-added', 'content:link-removed',
'content:tag-added', 'content:tag-removed', 'content:frontmatter-changed',
'content:heading-changed', 'content:embed-added', 'content:embed-removed',
'nav:file-open', 'nav:file-close', 'nav:active-leaf-change',
'editor:change', 'editor:selection', 'editor:paste',
'vault:folder-create', 'vault:folder-delete', 'vault:folder-rename',
'system:session-start', 'system:session-end', 'system:baseline-scan',
'system:rescan', 'system:maintenance',
'plugin:command-executed',
];
const CATEGORIES = [
'file', 'content', 'navigation', 'editor', 'vault', 'plugin', 'system',
];
const TABLE_NAMES = [
'events', 'sessions', 'baseline', 'daily_stats', 'monthly_stats',
'retention_log', 'schema_version',
'_files', '_links', '_tags', '_headings',
];
export class SqlAutocomplete {
private columnCache = new Map<string, string[]>();
constructor(private db: DatabaseManager) {
this.preloadColumns();
}
private preloadColumns(): void {
for (const table of TABLE_NAMES) {
try {
const rows = this.db.queryReadOnly(
`PRAGMA table_info('${table}')`,
) as { name: string }[];
this.columnCache.set(table, rows.map(r => r.name));
} catch {
// Virtual tables oder nicht existente Tabellen ueberspringen
}
}
}
getSuggestions(sql: string, cursorPos: number): Suggestion[] {
const textBefore = sql.substring(0, cursorPos);
const lastWord = getLastWord(textBefore);
if (!lastWord) return [];
const context = detectContext(textBefore);
const suggestions: Suggestion[] = [];
const lw = lastWord.toLowerCase();
switch (context) {
case 'from':
case 'join':
for (const t of TABLE_NAMES) {
if (t.toLowerCase().startsWith(lw)) {
suggestions.push({ text: t, type: 'table' });
}
}
break;
case 'select':
case 'where':
case 'group-by':
case 'order-by': {
// Spalten aus aktiven Tabellen
const tables = extractTables(textBefore);
for (const table of tables) {
const cols = this.columnCache.get(table) ?? [];
for (const col of cols) {
if (col.toLowerCase().startsWith(lw)) {
suggestions.push({ text: col, type: 'column', detail: table });
}
}
}
// Funktionen
for (const fn of SQL_FUNCTIONS) {
if (fn.toLowerCase().startsWith(lw)) {
suggestions.push({ text: fn, type: 'function' });
}
}
// Keywords
for (const kw of SQL_KEYWORDS) {
if (kw.toLowerCase().startsWith(lw)) {
suggestions.push({ text: kw, type: 'keyword' });
}
}
break;
}
case 'string':
// Event-Typen und Kategorien
for (const et of EVENT_TYPES) {
if (et.toLowerCase().startsWith(lw)) {
suggestions.push({ text: et, type: 'event-type' });
}
}
for (const cat of CATEGORIES) {
if (cat.toLowerCase().startsWith(lw)) {
suggestions.push({ text: cat, type: 'category' });
}
}
break;
default:
// Generisch: Alle Typen
for (const t of TABLE_NAMES) {
if (t.toLowerCase().startsWith(lw)) {
suggestions.push({ text: t, type: 'table' });
}
}
for (const kw of SQL_KEYWORDS) {
if (kw.toLowerCase().startsWith(lw)) {
suggestions.push({ text: kw, type: 'keyword' });
}
}
}
// Sortieren: exakte Prefix-Matches zuerst, dann alphabetisch
suggestions.sort((a, b) => {
const aExact = a.text.toLowerCase().startsWith(lw) ? 0 : 1;
const bExact = b.text.toLowerCase().startsWith(lw) ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
return a.text.localeCompare(b.text);
});
return suggestions.slice(0, 20);
}
getColumns(table: string): string[] {
return this.columnCache.get(table) ?? [];
}
}
// ---------------------------------------------------------------------------
// Context detection
// ---------------------------------------------------------------------------
type SqlContext = 'select' | 'from' | 'join' | 'where' | 'group-by' | 'order-by' | 'string' | 'unknown';
function detectContext(textBefore: string): SqlContext {
// In String-Kontext? (ungerade Anzahl an einfachen Anführungszeichen)
const quoteCount = (textBefore.match(/'/g) || []).length;
if (quoteCount % 2 === 1) return 'string';
const upper = textBefore.toUpperCase();
const lastKeyword = findLastKeyword(upper);
switch (lastKeyword) {
case 'SELECT': return 'select';
case 'FROM': return 'from';
case 'JOIN': return 'join';
case 'WHERE': case 'AND': case 'OR': case 'ON': case 'HAVING': return 'where';
case 'GROUP BY': return 'group-by';
case 'ORDER BY': return 'order-by';
default: return 'unknown';
}
}
function findLastKeyword(upper: string): string {
const keywords = [
'ORDER BY', 'GROUP BY', 'LEFT JOIN', 'INNER JOIN', 'JOIN',
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'ON', 'HAVING',
];
let lastPos = -1;
let lastKw = '';
for (const kw of keywords) {
const pos = upper.lastIndexOf(kw);
if (pos > lastPos) {
lastPos = pos;
lastKw = kw;
}
}
return lastKw;
}
function getLastWord(text: string): string {
const match = text.match(/[\w._:-]+$/);
return match ? match[0] : '';
}
function extractTables(text: string): string[] {
const upper = text.toUpperCase();
const tables: string[] = [];
const fromMatch = upper.match(/FROM\s+([\w_]+)/g);
if (fromMatch) {
for (const m of fromMatch) {
const name = m.replace(/FROM\s+/i, '').trim().toLowerCase();
if (TABLE_NAMES.includes(name)) tables.push(name);
}
}
const joinMatch = upper.match(/JOIN\s+([\w_]+)/g);
if (joinMatch) {
for (const m of joinMatch) {
const name = m.replace(/.*JOIN\s+/i, '').trim().toLowerCase();
if (TABLE_NAMES.includes(name)) tables.push(name);
}
}
return tables.length > 0 ? tables : ['events'];
}

View file

@ -4,6 +4,7 @@ import { buildQuery } from '../core/query-builder';
import { DatabaseManager } from '../core/database';
import { renderTable, toMarkdownTable } from '../viz/table-renderer';
import { HistoryManager } from '../management/history';
import type { SqlAutocomplete, Suggestion } from './autocomplete';
export class QueryModal extends Modal {
private editorEl!: HTMLTextAreaElement;
@ -12,11 +13,16 @@ export class QueryModal extends Modal {
private mode: 'shorthand' | 'sql' = 'shorthand';
private historyManager?: HistoryManager;
private initialSql?: string;
private autocomplete?: SqlAutocomplete;
private acDropdown: HTMLElement | null = null;
private acSuggestions: Suggestion[] = [];
private acIndex = -1;
constructor(app: App, private db: DatabaseManager, historyManager?: HistoryManager, initialSql?: string) {
constructor(app: App, private db: DatabaseManager, historyManager?: HistoryManager, initialSql?: string, autocomplete?: SqlAutocomplete) {
super(app);
this.historyManager = historyManager;
this.initialSql = initialSql;
this.autocomplete = autocomplete;
}
onOpen(): void {
@ -43,8 +49,10 @@ export class QueryModal extends Modal {
cls: 'logfire-qm-hint',
});
// Editor
this.editorEl = contentEl.createEl('textarea', {
// Editor (wrap in relative container for autocomplete dropdown)
const editorWrap = contentEl.createDiv({ cls: 'logfire-qm-editor-wrap' });
this.editorEl = editorWrap.createEl('textarea', {
cls: 'logfire-qm-editor',
attr: {
placeholder: 'events today\nstats this-week group by file\nfiles modified yesterday',
@ -53,12 +61,29 @@ export class QueryModal extends Modal {
});
this.editorEl.addEventListener('keydown', (e) => {
// Autocomplete navigation
if (this.acDropdown) {
if (e.key === 'ArrowDown') { e.preventDefault(); this.acNav(1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); this.acNav(-1); return; }
if (e.key === 'Tab' || (e.key === 'Enter' && !e.ctrlKey && !e.metaKey)) {
if (this.acIndex >= 0) { e.preventDefault(); this.acAccept(); return; }
}
if (e.key === 'Escape') { this.acClose(); return; }
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.acClose();
this.executeQuery();
}
});
this.editorEl.addEventListener('input', () => {
if (this.mode === 'sql' && this.autocomplete) {
this.showAutocomplete();
}
});
// Buttons
const buttonRow = contentEl.createDiv({ cls: 'logfire-qm-buttons' });
@ -71,6 +96,12 @@ export class QueryModal extends Modal {
const insertBtn = buttonRow.createEl('button', { text: 'In Notiz einf\u00fcgen' });
insertBtn.addEventListener('click', () => this.insertInNote());
const csvBtn = buttonRow.createEl('button', { text: 'CSV Export' });
csvBtn.addEventListener('click', () => this.exportCsv());
const jsonBtn = buttonRow.createEl('button', { text: 'JSON Export' });
jsonBtn.addEventListener('click', () => this.exportJson());
const clearBtn = buttonRow.createEl('button', { text: 'Leeren' });
clearBtn.addEventListener('click', () => {
this.editorEl.value = '';
@ -92,6 +123,7 @@ export class QueryModal extends Modal {
}
onClose(): void {
this.acClose();
this.contentEl.empty();
}
@ -187,6 +219,132 @@ export class QueryModal extends Modal {
view.editor.replaceSelection(md + '\n');
this.close();
}
// ---------------------------------------------------------------------------
// CSV / JSON Export
// ---------------------------------------------------------------------------
private exportCsv(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Exportieren.');
return;
}
const bom = '\uFEFF';
const header = this.lastKeys.map(csvEscape).join(',');
const body = this.lastRows.map(row =>
this.lastKeys.map(k => csvEscape(row[k])).join(',')
).join('\n');
downloadBlob(bom + header + '\n' + body, 'logfire-export.csv', 'text/csv;charset=utf-8');
new Notice('CSV exportiert.');
}
private exportJson(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Exportieren.');
return;
}
const json = JSON.stringify(this.lastRows, null, 2);
downloadBlob(json, 'logfire-export.json', 'application/json');
new Notice('JSON exportiert.');
}
// ---------------------------------------------------------------------------
// Autocomplete
// ---------------------------------------------------------------------------
private showAutocomplete(): void {
if (!this.autocomplete) return;
const cursorPos = this.editorEl.selectionStart;
const suggestions = this.autocomplete.getSuggestions(this.editorEl.value, cursorPos);
if (suggestions.length === 0) {
this.acClose();
return;
}
this.acSuggestions = suggestions;
this.acIndex = -1;
if (!this.acDropdown) {
this.acDropdown = this.editorEl.parentElement!.createDiv({ cls: 'logfire-ac-dropdown' });
}
this.acDropdown.empty();
for (let i = 0; i < suggestions.length; i++) {
const s = suggestions[i];
const item = this.acDropdown.createDiv({ cls: 'logfire-ac-item' });
item.createEl('span', { text: s.text, cls: 'logfire-ac-text' });
item.createEl('span', { text: s.type, cls: `logfire-ac-type logfire-ac-type-${s.type}` });
if (s.detail) {
item.createEl('span', { text: s.detail, cls: 'logfire-ac-detail' });
}
item.addEventListener('mousedown', (e) => {
e.preventDefault();
this.acIndex = i;
this.acAccept();
});
}
}
private acNav(dir: number): void {
if (!this.acDropdown || this.acSuggestions.length === 0) return;
this.acIndex = Math.max(-1, Math.min(this.acSuggestions.length - 1, this.acIndex + dir));
const items = this.acDropdown.querySelectorAll('.logfire-ac-item');
items.forEach((el, i) => el.classList.toggle('is-selected', i === this.acIndex));
}
private acAccept(): void {
if (this.acIndex < 0 || this.acIndex >= this.acSuggestions.length) return;
const suggestion = this.acSuggestions[this.acIndex];
const cursorPos = this.editorEl.selectionStart;
const text = this.editorEl.value;
const before = text.substring(0, cursorPos);
const after = text.substring(cursorPos);
// Letztes Wort ersetzen
const lastWordMatch = before.match(/[\w._:-]+$/);
const replaceStart = lastWordMatch ? cursorPos - lastWordMatch[0].length : cursorPos;
const newBefore = text.substring(0, replaceStart) + suggestion.text;
this.editorEl.value = newBefore + after;
this.editorEl.selectionStart = this.editorEl.selectionEnd = newBefore.length;
this.editorEl.focus();
this.acClose();
}
private acClose(): void {
this.acDropdown?.remove();
this.acDropdown = null;
this.acSuggestions = [];
this.acIndex = -1;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function csvEscape(value: unknown): string {
if (value === null || value === undefined) return '';
const s = String(value);
if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function downloadBlob(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------

View file

@ -290,10 +290,59 @@ export class LogfireSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
// Database info
// Database settings
advEl.createEl('h3', { text: 'Datenbank' });
const dbInfoEl = advEl.createDiv();
new Setting(advEl)
.setName('WAL-Modus')
.setDesc('Write-Ahead-Logging für bessere Parallelität (Neustart erforderlich).')
.addToggle(t => t
.setValue(this.plugin.settings.advanced.database.walMode)
.onChange(async (v) => {
this.plugin.settings.advanced.database.walMode = v;
await this.plugin.saveSettings();
}));
new Setting(advEl)
.setName('Cache-Größe (MB)')
.setDesc('SQLite Page-Cache (8256 MB). Mehr Cache = schnellere Queries.')
.addSlider(s => s
.setLimits(8, 256, 8)
.setValue(this.plugin.settings.advanced.database.cacheSizeMb)
.setDynamicTooltip()
.onChange(async (v) => {
this.plugin.settings.advanced.database.cacheSizeMb = v;
await this.plugin.saveSettings();
}));
new Setting(advEl)
.setName('MMap-Größe (MB)')
.setDesc('Memory-Mapped I/O (01024 MB). 0 deaktiviert MMap.')
.addSlider(s => s
.setLimits(0, 1024, 32)
.setValue(this.plugin.settings.advanced.database.mmapSizeMb)
.setDynamicTooltip()
.onChange(async (v) => {
this.plugin.settings.advanced.database.mmapSizeMb = v;
await this.plugin.saveSettings();
}));
new Setting(advEl)
.setName('Geschützte Event-Typen')
.setDesc('Event-Typen die nie gelöscht werden (komma-getrennt).')
.addText(t => t
.setPlaceholder('file:create, file:delete, file:rename')
.setValue(this.plugin.settings.advanced.retention.neverDeleteTypes.join(', '))
.onChange(async (v) => {
this.plugin.settings.advanced.retention.neverDeleteTypes = v
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0) as any[];
await this.plugin.saveSettings();
}));
// Database info
const dbInfoEl = advEl.createDiv({ cls: 'logfire-info-section' });
try {
const eventCount = this.plugin.db.getEventCount();
const dbSize = this.plugin.db.getDatabaseSizeBytes();
@ -306,6 +355,12 @@ export class LogfireSettingTab extends PluginSettingTab {
dbInfoEl.createEl('p', { text: 'Datenbank-Info nicht verfügbar.' });
}
// Info section
const infoEl = advEl.createDiv({ cls: 'logfire-info-section' });
infoEl.createEl('p', {
text: `Version: ${this.plugin.manifest.version} | Session: ${this.plugin.sessionManager.currentSessionId.substring(0, 8)}`,
});
new Setting(advEl)
.setName('Datenbank-Aktionen')
.addButton(b => b

234
src/viz/keyboard-nav.ts Normal file
View file

@ -0,0 +1,234 @@
import { Notice } from 'obsidian';
export class KeyboardNavigator {
private container: HTMLElement | null = null;
private table: HTMLTableElement | null = null;
private row = 0;
private col = 0;
private rows: HTMLTableRowElement[] = [];
private active = false;
private searchMode = false;
private searchOverlay: HTMLElement | null = null;
private searchInput: HTMLInputElement | null = null;
private searchQuery = '';
private boundHandler: (e: KeyboardEvent) => void;
constructor(private app: { workspace: { openLinkText(link: string, source: string): void } }) {
this.boundHandler = this.handleKeydown.bind(this);
}
attach(container: HTMLElement): boolean {
const table = container.querySelector<HTMLTableElement>('table.logfire-table');
if (!table) return false;
this.container = container;
this.table = table;
this.rows = Array.from(table.querySelectorAll('tbody tr'));
if (this.rows.length === 0) return false;
this.active = true;
this.row = 0;
this.col = 0;
container.tabIndex = 0;
container.focus();
container.addEventListener('keydown', this.boundHandler);
this.highlight();
return true;
}
detach(): void {
this.clearHighlight();
this.closeSearch();
if (this.container) {
this.container.removeEventListener('keydown', this.boundHandler);
}
this.active = false;
this.container = null;
this.table = null;
this.rows = [];
}
isActive(): boolean {
return this.active;
}
// ---------------------------------------------------------------------------
// Keydown handler
// ---------------------------------------------------------------------------
private handleKeydown(e: KeyboardEvent): void {
if (!this.active) return;
if (this.searchMode) {
this.handleSearchKey(e);
return;
}
switch (e.key) {
case 'j': this.move(1, 0); break;
case 'k': this.move(-1, 0); break;
case 'l': this.move(0, 1); break;
case 'h': this.move(0, -1); break;
case 'g':
if (this.pendingG) {
this.goTop();
this.pendingG = false;
} else {
this.pendingG = true;
setTimeout(() => { this.pendingG = false; }, 500);
return;
}
break;
case 'G': this.goBottom(); break;
case '/': this.openSearch(); break;
case 'y': this.yankCell(); break;
case 'Enter': this.openFile(); break;
case 'Escape': this.detach(); break;
default: return;
}
e.preventDefault();
e.stopPropagation();
}
private pendingG = false;
// ---------------------------------------------------------------------------
// Movement
// ---------------------------------------------------------------------------
private move(dr: number, dc: number): void {
this.clearHighlight();
this.row = clamp(this.row + dr, 0, this.rows.length - 1);
const cellCount = this.rows[this.row]?.cells.length ?? 1;
this.col = clamp(this.col + dc, 0, cellCount - 1);
this.highlight();
this.scrollIntoView();
}
private goTop(): void {
this.clearHighlight();
this.row = 0;
this.highlight();
this.scrollIntoView();
}
private goBottom(): void {
this.clearHighlight();
this.row = this.rows.length - 1;
this.highlight();
this.scrollIntoView();
}
// ---------------------------------------------------------------------------
// Highlight
// ---------------------------------------------------------------------------
private highlight(): void {
const tr = this.rows[this.row];
if (!tr) return;
tr.classList.add('logfire-vim-row');
const td = tr.cells[this.col];
if (td) td.classList.add('logfire-vim-cell');
}
private clearHighlight(): void {
for (const tr of this.rows) {
tr.classList.remove('logfire-vim-row');
for (const td of Array.from(tr.cells)) {
td.classList.remove('logfire-vim-cell');
}
}
}
private scrollIntoView(): void {
this.rows[this.row]?.scrollIntoView({ block: 'nearest' });
}
// ---------------------------------------------------------------------------
// Yank
// ---------------------------------------------------------------------------
private yankCell(): void {
const td = this.rows[this.row]?.cells[this.col];
if (!td) return;
navigator.clipboard.writeText(td.textContent ?? '');
td.classList.add('logfire-vim-yanked');
setTimeout(() => td.classList.remove('logfire-vim-yanked'), 600);
new Notice('In Zwischenablage kopiert.');
}
// ---------------------------------------------------------------------------
// Open file
// ---------------------------------------------------------------------------
private openFile(): void {
const td = this.rows[this.row]?.cells[this.col];
if (!td) return;
const text = td.textContent?.trim() ?? '';
if (text.endsWith('.md') || text.includes('/')) {
this.app.workspace.openLinkText(text, '');
}
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
private openSearch(): void {
if (!this.container) return;
this.searchMode = true;
this.searchQuery = '';
this.searchOverlay = this.container.createDiv({ cls: 'logfire-vim-search' });
this.searchOverlay.createEl('span', { text: '/' });
this.searchInput = this.searchOverlay.createEl('input', {
attr: { type: 'text', placeholder: 'Suche...' },
});
this.searchInput.focus();
}
private handleSearchKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
this.closeSearch();
e.preventDefault();
return;
}
if (e.key === 'Enter') {
this.searchQuery = this.searchInput?.value ?? '';
this.closeSearch();
this.findNext();
e.preventDefault();
return;
}
// Let normal typing pass through to the input
}
private closeSearch(): void {
this.searchOverlay?.remove();
this.searchOverlay = null;
this.searchInput = null;
this.searchMode = false;
this.container?.focus();
}
private findNext(): void {
if (!this.searchQuery) return;
const q = this.searchQuery.toLowerCase();
for (let i = 1; i <= this.rows.length; i++) {
const idx = (this.row + i) % this.rows.length;
const text = this.rows[idx].textContent?.toLowerCase() ?? '';
if (text.includes(q)) {
this.clearHighlight();
this.row = idx;
this.highlight();
this.scrollIntoView();
return;
}
}
new Notice('Nicht gefunden.');
}
}
function clamp(val: number, min: number, max: number): number {
return Math.max(min, Math.min(max, val));
}

View file

@ -1210,3 +1210,137 @@
margin-bottom: 10px;
color: var(--text-normal);
}
/*
Vim-Navigation Keyboard Table Navigation
*/
.logfire-vim-row {
background: color-mix(in srgb, var(--interactive-accent) 15%, transparent) !important;
}
.logfire-vim-cell {
outline: 2px solid var(--interactive-accent);
outline-offset: -1px;
}
.logfire-vim-yanked {
animation: logfire-yank-flash 600ms ease;
}
@keyframes logfire-yank-flash {
0% { background: color-mix(in srgb, var(--interactive-accent) 40%, transparent); }
100% { background: transparent; }
}
.logfire-vim-search {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--background-secondary);
border-top: 1px solid var(--background-modifier-border);
font-family: var(--font-monospace);
font-size: 12px;
color: var(--text-normal);
z-index: 10;
}
.logfire-vim-search span {
color: var(--interactive-accent);
font-weight: 600;
}
.logfire-vim-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-normal);
font-family: var(--font-monospace);
font-size: 12px;
}
/*
Autocomplete Dropdown
*/
.logfire-qm-editor-wrap {
position: relative;
}
.logfire-ac-dropdown {
position: absolute;
bottom: 0;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 0 0 4px 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 20;
font-family: var(--font-monospace);
font-size: 11px;
}
.logfire-ac-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
cursor: pointer;
transition: background 60ms ease;
}
.logfire-ac-item:hover,
.logfire-ac-item.is-selected {
background: color-mix(in srgb, var(--interactive-accent) 15%, transparent);
}
.logfire-ac-text {
color: var(--text-normal);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logfire-ac-type {
font-size: 9px;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 1px 4px;
border-radius: 2px;
flex-shrink: 0;
}
.logfire-ac-type-table { color: var(--interactive-accent); background: color-mix(in srgb, var(--interactive-accent) 12%, transparent); }
.logfire-ac-type-column { color: var(--text-accent); background: color-mix(in srgb, var(--text-accent) 12%, transparent); }
.logfire-ac-type-keyword { color: var(--text-muted); background: var(--background-secondary); }
.logfire-ac-type-function { color: var(--text-normal); background: var(--background-secondary); }
.logfire-ac-type-event-type { color: var(--text-error, #e5534b); background: color-mix(in srgb, var(--text-error, #e5534b) 10%, transparent); }
.logfire-ac-type-category { color: var(--text-faint); background: var(--background-secondary); }
.logfire-ac-detail {
color: var(--text-faint);
font-size: 9px;
flex-shrink: 0;
}
/*
Info Section Version & Session
*/
.logfire-info-section {
font-family: var(--font-monospace);
font-size: 11px;
color: var(--text-muted);
padding: 4px 0;
font-variant-numeric: tabular-nums;
}