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:
commit
878b144ccc
6 changed files with 860 additions and 7 deletions
48
src/main.ts
48
src/main.ts
|
|
@ -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
228
src/query/autocomplete.ts
Normal 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'];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 (8–256 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 (0–1024 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
234
src/viz/keyboard-nav.ts
Normal 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));
|
||||
}
|
||||
134
styles.css
134
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue