From c2b7918ce499b19b724d87b6f2252f596a095279 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:55:44 +0100 Subject: [PATCH] =?UTF-8?q?Feature=209:=20Polish=20&=20Extras=20=E2=80=94?= =?UTF-8?q?=20Vim-Navigation,=20Autocomplete,=20Export,=20erweiterte=20Set?= =?UTF-8?q?tings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/main.ts | 48 +++++++- src/query/autocomplete.ts | 228 +++++++++++++++++++++++++++++++++++++ src/query/query-modal.ts | 164 +++++++++++++++++++++++++- src/ui/settings-tab.ts | 59 +++++++++- src/viz/keyboard-nav.ts | 234 ++++++++++++++++++++++++++++++++++++++ styles.css | 134 ++++++++++++++++++++++ 6 files changed, 860 insertions(+), 7 deletions(-) create mode 100644 src/query/autocomplete.ts create mode 100644 src/viz/keyboard-nav.ts diff --git a/src/main.ts b/src/main.ts index fbbdcba..59766f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,8 @@ import { HistoryManager } from './management/history'; import { FavoritesManager, SaveFavoriteModal } from './management/favorites'; import { TemplateManager, TemplatePickerModal } from './management/templates'; import { SchemaView, SCHEMA_VIEW_TYPE } from './ui/schema-view'; +import { KeyboardNavigator } from './viz/keyboard-nav'; +import { SqlAutocomplete } from './query/autocomplete'; export default class LogfirePlugin extends Plugin { settings!: LogfireSettings; @@ -31,6 +33,7 @@ export default class LogfirePlugin extends Plugin { historyManager!: HistoryManager; favoritesManager!: FavoritesManager; templateManager!: TemplateManager; + autocomplete!: SqlAutocomplete; private fileCollector!: FileCollector; private contentCollector!: ContentCollector; @@ -39,6 +42,7 @@ export default class LogfirePlugin extends Plugin { private systemCollector!: SystemCollector; private statusBar!: StatusBar; private virtualTables!: VirtualTableManager; + private keyboardNav!: KeyboardNavigator; private paused = false; @@ -164,6 +168,12 @@ export default class LogfirePlugin extends Plugin { // Virtual Tables this.virtualTables = new VirtualTableManager(this.app, this.db); this.virtualTables.initialize(); + + // 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); @@ -339,7 +349,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(); }, }); @@ -354,7 +364,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(); }, }); @@ -382,6 +392,40 @@ export default class LogfirePlugin extends Plugin { }); }, }); + + 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.'); + } + } + }, + }); } // --------------------------------------------------------------------------- diff --git a/src/query/autocomplete.ts b/src/query/autocomplete.ts new file mode 100644 index 0000000..b40011b --- /dev/null +++ b/src/query/autocomplete.ts @@ -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(); + + 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']; +} diff --git a/src/query/query-modal.ts b/src/query/query-modal.ts index db2973a..ad9b1c1 100644 --- a/src/query/query-modal.ts +++ b/src/query/query-modal.ts @@ -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); } // --------------------------------------------------------------------------- diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index b97ccf3..61af43e 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -217,10 +217,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(); @@ -233,6 +282,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 diff --git a/src/viz/keyboard-nav.ts b/src/viz/keyboard-nav.ts new file mode 100644 index 0000000..29981cf --- /dev/null +++ b/src/viz/keyboard-nav.ts @@ -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('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)); +} diff --git a/styles.css b/styles.css index bdae9eb..061e86c 100644 --- a/styles.css +++ b/styles.css @@ -1197,3 +1197,137 @@ max-height: 120px; overflow: auto; } + +/* ═══════════════════════════════════════════════════════════════════════════ + 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; +}