obsidian-logfire/src/query/query-modal.ts
tolvitty c2b7918ce4 Feature 9: Polish & Extras — Vim-Navigation, Autocomplete, Export, erweiterte Settings
- keyboard-nav.ts: Vim-Navigation fuer Tabellen (j/k/h/l, gg/G, /, y, Enter, Esc)
- autocomplete.ts: SQL-Autocomplete mit Kontext-Erkennung (Tabellen, Spalten, Keywords, Funktionen, Event-Typen)
- query-modal.ts: CSV/JSON-Export-Buttons, Autocomplete-Integration (Dropdown, Arrow-Nav, Tab/Enter Accept)
- settings-tab.ts: Erweiterte DB-Settings (WAL-Toggle, Cache-Slider, MMap-Slider), geschuetzte Event-Typen, Info-Sektion
- main.ts: SqlAutocomplete + KeyboardNavigator Integration, 3 neue Commands (export-csv, export-json, toggle-vim-nav)
- styles.css: Vim-Navigation (.logfire-vim-*), Autocomplete-Dropdown (.logfire-ac-*), Info-Sektion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:55:44 +01:00

403 lines
13 KiB
TypeScript

import { App, Modal, MarkdownView, Notice } from 'obsidian';
import { QueryConfig, TimeRange, EventType } from '../types';
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;
private resultEl!: HTMLElement;
private modeToggle!: HTMLButtonElement;
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, autocomplete?: SqlAutocomplete) {
super(app);
this.historyManager = historyManager;
this.initialSql = initialSql;
this.autocomplete = autocomplete;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('logfire-query-modal');
contentEl.createEl('h2', { text: 'Logfire Query' });
// Mode toggle
const toolbar = contentEl.createDiv({ cls: 'logfire-qm-toolbar' });
this.modeToggle = toolbar.createEl('button', { text: 'Modus: Shorthand' });
this.modeToggle.addEventListener('click', () => {
this.mode = this.mode === 'shorthand' ? 'sql' : 'shorthand';
this.modeToggle.textContent = this.mode === 'shorthand' ? 'Modus: Shorthand' : 'Modus: SQL';
this.editorEl.placeholder = this.mode === 'shorthand'
? 'events today\nstats this-week group by file\nfiles modified yesterday'
: 'SELECT * FROM events\nWHERE type = \'file:create\'\nORDER BY timestamp DESC\nLIMIT 50';
});
const helpSpan = toolbar.createEl('span', {
text: 'Ctrl+Enter: Ausf\u00fchren',
cls: 'logfire-qm-hint',
});
// 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',
spellcheck: 'false',
},
});
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' });
const runBtn = buttonRow.createEl('button', { text: 'Ausf\u00fchren', cls: 'mod-cta' });
runBtn.addEventListener('click', () => this.executeQuery());
const copyBtn = buttonRow.createEl('button', { text: 'Als Markdown kopieren' });
copyBtn.addEventListener('click', () => this.copyAsMarkdown());
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 = '';
this.resultEl.empty();
this.lastRows = [];
this.lastKeys = [];
});
// Results
this.resultEl = contentEl.createDiv({ cls: 'logfire-qm-results' });
// Initial SQL setzen
if (this.initialSql) {
this.mode = 'sql';
this.modeToggle.textContent = 'Modus: SQL';
this.editorEl.value = this.initialSql;
this.editorEl.placeholder = 'SELECT * FROM events\nWHERE type = \'file:create\'\nORDER BY timestamp DESC\nLIMIT 50';
}
}
onClose(): void {
this.acClose();
this.contentEl.empty();
}
private lastRows: Record<string, unknown>[] = [];
private lastKeys: string[] = [];
private executeQuery(): void {
const input = this.editorEl.value.trim();
if (!input) return;
this.resultEl.empty();
const startTime = Date.now();
try {
let rows: Record<string, unknown>[];
let executedSql = input;
if (this.mode === 'sql') {
// Raw SQL mode
const firstWord = input.split(/\s+/)[0].toUpperCase();
if (firstWord !== 'SELECT' && firstWord !== 'WITH') {
this.resultEl.createEl('pre', {
text: 'Nur SELECT- und WITH-Queries sind erlaubt.',
cls: 'logfire-error',
});
return;
}
rows = this.db.queryReadOnly(input) as Record<string, unknown>[];
} else {
// Shorthand mode
const config = parseShorthand(input);
const { sql, params } = buildQuery(config);
executedSql = sql;
rows = this.db.queryReadOnly(sql, params) as Record<string, unknown>[];
}
const elapsed = Date.now() - startTime;
this.historyManager?.addEntry(executedSql, elapsed, rows.length);
this.lastRows = rows;
this.lastKeys = rows.length > 0 ? Object.keys(rows[0]) : [];
this.renderResults(rows);
} catch (err) {
this.resultEl.createEl('pre', {
text: `Fehler: ${err instanceof Error ? err.message : String(err)}`,
cls: 'logfire-error',
});
}
}
private renderResults(rows: Record<string, unknown>[]): void {
this.resultEl.empty();
if (rows.length === 0) {
this.resultEl.createEl('p', { text: 'Keine Ergebnisse.' });
return;
}
const displayRows = rows.slice(0, 200);
renderTable(this.resultEl, displayRows);
if (rows.length > 200) {
this.resultEl.createEl('p', {
text: `${rows.length} Ergebnisse, 200 angezeigt.`,
cls: 'logfire-qm-truncated',
});
}
}
private copyAsMarkdown(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Kopieren.');
return;
}
const md = toMarkdownTable(this.lastKeys, this.lastRows);
navigator.clipboard.writeText(md);
new Notice('In Zwischenablage kopiert.');
}
private insertInNote(): void {
if (this.lastRows.length === 0) {
new Notice('Keine Ergebnisse zum Einf\u00fcgen.');
return;
}
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('Kein aktiver Editor zum Einf\u00fcgen.');
return;
}
const md = toMarkdownTable(this.lastKeys, this.lastRows);
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);
}
// ---------------------------------------------------------------------------
// Shorthand parser
// ---------------------------------------------------------------------------
function parseShorthand(input: string): QueryConfig {
const lower = input.toLowerCase().trim();
let timeRange: TimeRange = { type: 'relative', value: 'today' };
let eventTypes: EventType[] | undefined;
let groupBy: QueryConfig['groupBy'];
let limit = 50;
const timeRanges: Record<string, TimeRange> = {
'today': { type: 'relative', value: 'today' },
'yesterday': { type: 'relative', value: 'yesterday' },
'this-week': { type: 'relative', value: 'this-week' },
'this week': { type: 'relative', value: 'this-week' },
'this-month': { type: 'relative', value: 'this-month' },
'this month': { type: 'relative', value: 'this-month' },
'last-7-days': { type: 'relative', value: 'last-7-days' },
'last 7 days': { type: 'relative', value: 'last-7-days' },
'last-30-days': { type: 'relative', value: 'last-30-days' },
'last 30 days': { type: 'relative', value: 'last-30-days' },
};
for (const [key, range] of Object.entries(timeRanges)) {
if (lower.includes(key)) {
timeRange = range;
break;
}
}
const groupMatch = lower.match(/group\s+by\s+(\w+)/);
if (groupMatch) {
groupBy = groupMatch[1] as QueryConfig['groupBy'];
}
const limitMatch = lower.match(/limit\s+(\d+)/);
if (limitMatch) {
limit = parseInt(limitMatch[1], 10);
}
if (lower.includes('stats')) {
groupBy = groupBy ?? 'day';
}
if (lower.includes('files modified')) {
eventTypes = ['file:modify'];
}
if (lower.includes('files created')) {
eventTypes = ['file:create'];
}
return { timeRange, eventTypes, groupBy, limit };
}