- 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>
403 lines
13 KiB
TypeScript
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 };
|
|
}
|