diff --git a/vscode-appdaemon/package.json b/vscode-appdaemon/package.json index 6460e94..add5e54 100644 --- a/vscode-appdaemon/package.json +++ b/vscode-appdaemon/package.json @@ -96,10 +96,20 @@ } ], "keybindings": [ + { + "command": "appdaemon.goToVirtualSensor", + "key": "ctrl+shift+v", + "mac": "cmd+shift+v" + }, { "command": "appdaemon.goToApp", "key": "ctrl+shift+a", "mac": "cmd+shift+a" + }, + { + "command": "appdaemon.goToVirtualSensor", + "key": "ctrl+shift+v", + "mac": "cmd+shift+v" } ] }, diff --git a/vscode-appdaemon/src/extension.ts b/vscode-appdaemon/src/extension.ts index 5002c71..6d670b0 100644 --- a/vscode-appdaemon/src/extension.ts +++ b/vscode-appdaemon/src/extension.ts @@ -75,6 +75,122 @@ async function findAllApps(): Promise { + const lines = text.split('\n'); + const entries: Array<{ sensorId: string; line: number; appName: string }> = []; + + let currentApp = ''; + let isVSApp = false; // module: virtualsensors + let vsSection: VSSectionType = ''; // active sub-section in VirtualSensorsApp at indent=2 + let inVSKey = false; // in virtual_sensors: sub-block of another app + let nestedSection: VSSectionType = ''; // active sub-section in virtual_sensors: at indent=4 + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const trimmed = raw.trimStart(); + if (!trimmed || trimmed.startsWith('#')) { continue; } + const indent = raw.length - trimmed.length; + + if (indent === 0) { + const m = raw.match(/^([a-z][a-z0-9_]*):\s*$/); + currentApp = m ? m[1] : ''; + isVSApp = false; vsSection = ''; inVSKey = false; nestedSection = ''; + continue; + } + + if (indent === 2) { + vsSection = ''; inVSKey = false; nestedSection = ''; + if (/^\s+module:\s*virtualsensors/.test(raw)) { + isVSApp = true; + } else if (isVSApp) { + vsSection = parseVSSectionName(raw, 2); + } else { + if (/^\s+virtual_sensors:\s*$/.test(raw)) { inVSKey = true; } + } + continue; + } + + if (indent === 4) { + nestedSection = ''; + if (isVSApp && vsSection) { + const km = raw.match(/^\s+([a-z][a-z0-9_.]+):/); + if (km) { + const sensorId = mapVirtualSensorId(km[1], vsSection); + if (sensorId) { entries.push({ sensorId, line: i, appName: currentApp }); } + } + } else if (inVSKey) { + nestedSection = parseVSSectionName(raw, 4); + } + continue; + } + + if (indent === 6 && inVSKey && nestedSection) { + const km = raw.match(/^\s+([a-z][a-z0-9_.]+):/); + if (km) { + const sensorId = mapVirtualSensorId(km[1], nestedSection); + if (sensorId) { entries.push({ sensorId, line: i, appName: currentApp }); } + } + } + } + return entries; +} + +async function findAllVirtualSensors(): Promise { + const yamlFiles = await vscode.workspace.findFiles('**/*.yaml', '**/node_modules/**'); + const results: VirtualSensorEntry[] = []; + for (const uri of yamlFiles) { + let doc: vscode.TextDocument; + try { doc = await vscode.workspace.openTextDocument(uri); } catch { continue; } + const fileName = vscode.workspace.asRelativePath(uri); + for (const e of parseVirtualSensorsFromText(doc.getText())) { + results.push({ ...e, uri, fileName }); + } + } + return results; +} + // ── Symbol Providers ───────────────────────────────────────────────────────── class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider { @@ -97,9 +213,9 @@ class ADDocumentSymbolProvider implements vscode.DocumentSymbolProvider { class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { async provideWorkspaceSymbols(query: string): Promise { - const apps = await findAllApps(); const lq = query.toLowerCase(); - return apps + const [apps, sensors] = await Promise.all([findAllApps(), findAllVirtualSensors()]); + const appSymbols = apps .filter(a => !lq || a.appName.toLowerCase().includes(lq)) .map(a => new vscode.SymbolInformation( a.appName, @@ -107,6 +223,62 @@ class ADWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { `module: ${a.moduleName}`, new vscode.Location(a.uri, new vscode.Position(a.line, 0)) )); + const sensorSymbols = sensors + .filter(s => !lq || s.sensorId.toLowerCase().includes(lq)) + .map(s => new vscode.SymbolInformation( + s.sensorId, + vscode.SymbolKind.Variable, + s.appName, + new vscode.Location(s.uri, new vscode.Position(s.line, 0)) + )); + return [...appSymbols, ...sensorSymbols]; + } +} + +class ADVirtualSensorDefinitionProvider implements vscode.DefinitionProvider { + // Simple cache: reset on save + private cache: VirtualSensorEntry[] | undefined; + private cacheExpiry = 0; + + constructor(private haClient: HAClient) {} + + invalidate() { this.cache = undefined; } + + private async getSensors(): Promise { + const now = Date.now(); + if (this.cache && now < this.cacheExpiry) { return this.cache; } + this.cache = await findAllVirtualSensors(); + this.cacheExpiry = now + 5000; // 5 s TTL + return this.cache; + } + + async provideDefinition( + document: vscode.TextDocument, + position: vscode.Position + ): Promise { + // Match any entity ID pattern (same as hover provider) + const wordRange = document.getWordRangeAtPosition( + position, + /[a-z][a-z_]*\.[a-z0-9][a-z0-9_]*/ + ); + if (!wordRange) { return undefined; } + const entityId = document.getText(wordRange); + + // 1. Check virtual sensor index (parsed from YAML) + const sensors = await this.getSensors(); + const vsMatch = sensors.find(s => s.sensorId === entityId); + if (vsMatch) { + return new vscode.Location(vsMatch.uri, new vscode.Position(vsMatch.line, 0)); + } + + // 2. Fallback: check HA entity's ad_app attribute → navigate to that app + const entity = this.haClient.getEntity(entityId); + const adApp = entity?.attributes?.ad_app as string | undefined; + if (!adApp) { return undefined; } + const apps = await findAllApps(); + const appMatch = apps.find(a => a.appName === adApp); + if (!appMatch) { return undefined; } + return new vscode.Location(appMatch.uri, new vscode.Position(appMatch.line, 0)); } } @@ -166,6 +338,15 @@ export async function activate(context: vscode.ExtensionContext) { { scheme: 'file', language: 'python' } ]; + const virtualSensorDefProvider = new ADVirtualSensorDefinitionProvider(haClient); + + // Invalidate definition cache on save so edits are picked up immediately + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument(doc => { + if (doc.languageId === 'yaml') { virtualSensorDefProvider.invalidate(); } + }) + ); + context.subscriptions.push( vscode.languages.registerCompletionItemProvider( selector, @@ -182,12 +363,42 @@ export async function activate(context: vscode.ExtensionContext) { ), vscode.languages.registerWorkspaceSymbolProvider( new ADWorkspaceSymbolProvider() + ), + vscode.languages.registerDefinitionProvider( + { scheme: 'file', language: 'yaml' }, + virtualSensorDefProvider ) ); // ── Commands ───────────────────────────────────────────────────────────── context.subscriptions.push( + vscode.commands.registerCommand('appdaemon.goToVirtualSensor', async () => { + const sensors = await findAllVirtualSensors(); + if (sensors.length === 0) { + vscode.window.showWarningMessage('AppDaemon: No virtual sensors found in workspace'); + return; + } + type SensorItem = vscode.QuickPickItem & { sensor: VirtualSensorEntry }; + const items: SensorItem[] = sensors.map(s => ({ + label: `$(circuit-board) ${s.sensorId}`, + description: s.fileName, + detail: `in ${s.appName}`, + sensor: s + })); + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Go to virtual sensor…', + matchOnDescription: true, + matchOnDetail: true + }); + if (!picked) { return; } + const doc = await vscode.workspace.openTextDocument(picked.sensor.uri); + const editor = await vscode.window.showTextDocument(doc); + const pos = new vscode.Position(picked.sensor.line, 0); + editor.selection = new vscode.Selection(pos, pos); + editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); + }), + vscode.commands.registerCommand('appdaemon.goToApp', async () => { const apps = await findAllApps(); if (apps.length === 0) { diff --git a/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix b/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix index b233536..99eb3b6 100644 Binary files a/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix and b/vscode-appdaemon/vscode-appdaemon-0.1.0.vsix differ