diff --git a/package.json b/package.json index ffb482b9..57ac9ed1 100644 --- a/package.json +++ b/package.json @@ -216,6 +216,11 @@ "category": "Make Apps", "icon": "$(search)" }, + { + "command": "apps-sdk.open-referenced-component", + "title": "Open referenced component", + "category": "Make Apps" + }, { "command": "apps-sdk.refresh", "title": "Refresh", @@ -536,6 +541,12 @@ ] }, "menus": { + "commandPalette": [ + { + "command": "apps-sdk.open-referenced-component", + "when": "false" + } + ], "editor/title": [ { "when": "resourceFilename == interface.imljson", diff --git a/src/extension.ts b/src/extension.ts index 472e3653..aeb85768 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,12 @@ import { import { AppsProvider } from './providers/AppsProvider'; import { OpensourceProvider } from './providers/OpensourceProvider'; import ImljsonHoverProvider = require('./providers/ImljsonHoverProvider'); +import { + ComponentReferenceHoverProvider, + type OpenReferencedComponentTarget, +} from './providers/ComponentReferenceHoverProvider'; +import { REFERENCE_CODE_DEF } from './libs/component-reference'; +import Code from './tree/Code'; import RpcCommands = require('./commands/RpcCommands'); import { EndpointCommands } from './commands/EndpointCommands'; import ModuleCommands = require('./commands/ModuleCommands'); @@ -38,11 +44,13 @@ import EnvironmentCommands = require('./commands/EnvironmentCommands'); import PublicCommands = require('./commands/PublicCommands'); import { telemetryReporter, sendTelemetry, startAppInsights } from './utils/telemetry'; import { getMakecomappJson, getMakecomappRootDir } from './local-development/makecomappjson'; +import { MAKECOMAPP_FILENAME } from './local-development/consts'; import { type AppComponentType, AppComponentTypes } from './types/app-component-type.types'; import { deleteLocalComponent } from './local-development/delete-local-component'; import { catchError } from './error-handling'; import { camelToKebab } from './utils/camel-to-kebab'; -import { contextGuard } from './Core'; +import { contextGuard, pathDeterminer } from './Core'; +import type { AppComponentSummary } from './libs/app-component-search'; let client: vscodeLanguageclient.LanguageClient; @@ -258,6 +266,106 @@ export async function activate(context: vscode.ExtensionContext) { await appsTreeView.reveal(item, { select: true, focus: true, expand: true }); })); + // Hover-to-open for component references (rpc://Name and custom IML function calls) in app code. + const componentReferenceHoverProvider = new ComponentReferenceHoverProvider(_authorization, _environment); + vscode.languages.registerHoverProvider( + [ + { language: 'imljson', scheme: 'file' }, + { language: 'javascript', scheme: 'file' }, + ], + componentReferenceHoverProvider, + ); + // A directory hovered before a makecomapp.json existed there (e.g. before an app was cloned + // into it) is cached as "not a Make project". Clear that cache whenever one appears/disappears + // anywhere in the workspace, so hover does not stay dead until a window reload. + const makecomappJsonWatcher = vscode.workspace.createFileSystemWatcher(`**/${MAKECOMAPP_FILENAME}`); + makecomappJsonWatcher.onDidCreate(() => componentReferenceHoverProvider.clearLocalAppRootCache()); + makecomappJsonWatcher.onDidDelete(() => componentReferenceHoverProvider.clearLocalAppRootCache()); + context.subscriptions.push(makecomappJsonWatcher); + + vscode.commands.registerCommand( + 'apps-sdk.open-referenced-component', + catchError('Open referenced component', async (target: OpenReferencedComponentTarget) => { + if (!target) { + return; + } + + // Local-development mode: open the on-disk code file. Reveal in the *file explorer* + // (not the Custom apps tree) — local projects are not Custom apps tree nodes. + if (target.mode === 'local') { + const uri = vscode.Uri.parse(target.fileUri); + await vscode.window.showTextDocument(uri, { preview: true }); + await vscode.commands.executeCommand('revealInExplorer', uri); + return; + } + + // Online mode: fetch the live App tree node (same parent chain as search-components, so + // TreeView.reveal matches by identity/id against getChildren) and the app's component + // summary in parallel — they are independent requests. Show progress since the app-list + // fetch is not cached and can be slow on accounts with many apps. + const { apps, components } = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Opening ${target.componentName}…` }, + async () => { + const [apps, componentsResult] = await Promise.all([ + // Tolerate a failure here: the file can still be opened via target.appName/ + // appVersion below using a minimal fallback ancestor. Only the component-summary + // fetch (needed to find the code file) should fail the whole command. + appsProvider.getChildren().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + log('warn', `Open referenced component: failed to load the apps list: ${message}`); + return undefined; + }), + // Reuse the hover provider's cache so a click right after hover does not re-fetch. + componentReferenceHoverProvider.getComponentsForApp(target.appName, target.appVersion), + ]); + return { apps: apps ?? [], components: componentsResult.components }; + }, + ); + + // Falls back to a minimal ancestor if the app list failed to load, or if the app is + // currently hidden by an active Custom apps search filter (getChildren() applies it). + // In that case the reveal below will likely fail silently (caught and logged) since the + // tree itself is not showing the app — the file still opens either way. + const appNode = + apps.find( + (app: { name?: string; version?: number }) => + app.name === target.appName && app.version === target.appVersion, + ) ?? { + id: `${target.appName}@${target.appVersion}`, + name: target.appName, + version: target.appVersion, + parent: undefined, + changes: [], + }; + + const summary: AppComponentSummary | undefined = components.find( + (component) => component.supertype === target.supertype && component.name === target.componentName, + ); + if (!summary) { + vscode.window.showWarningMessage(`Component "${target.componentName}" was not found in the app.`); + return; + } + + const item = appsProvider.buildComponentTreeItem(appNode, summary); + // RPC code lives in the "api" (imljson) file; function code in the "code" (js) file. + // Shared with ComponentReferenceHoverProvider's local-dev resolution so this mapping is + // defined in exactly one place for this feature (see REFERENCE_CODE_DEF's own doc comment + // for why it does not also unify with AppsProvider.js / component-code-def.ts). + const { apiCodeType: codeName, language } = REFERENCE_CODE_DEF[target.supertype]; + const apiPath = pathDeterminer(target.supertype); + const codeNode = new Code(codeName, codeName, item, language, apiPath, false, null, undefined); + + await vscode.commands.executeCommand('apps-sdk.load-source', codeNode); + try { + await appsTreeView.reveal(item, { select: true, focus: true, expand: true }); + } catch (err: unknown) { + // Opening the file already succeeded; reveal is best-effort (e.g. filtered tree). + const message = err instanceof Error ? err.message : String(err); + log('warn', `Open referenced component: tree reveal failed for ${target.componentName}: ${message}`); + } + }), + ); + /** * Registering commands */ diff --git a/src/libs/component-reference.test.ts b/src/libs/component-reference.test.ts new file mode 100644 index 00000000..eb10a2fb --- /dev/null +++ b/src/libs/component-reference.test.ts @@ -0,0 +1,94 @@ +import * as assert from 'node:assert'; +import { suite, test } from 'mocha'; +import { detectReferenceAt, parseOnlineAppContext } from './component-reference'; + +suite('component-reference detectReferenceAt()', () => { + test('Detects an rpc:// reference when the cursor is over it', () => { + const line = '\t\t"url": "rpc://getVendors"'; + const column = line.indexOf('getVendors'); + assert.deepStrictEqual(detectReferenceAt(line, column), { + kind: 'rpc', + name: 'getVendors', + startColumn: line.indexOf('rpc://'), + endColumn: line.indexOf('getVendors') + 'getVendors'.length, + }); + }); + + test('rpc hover range spans the whole rpc:// token including the prefix', () => { + const line = 'value rpc://My-Rpc_1 end'; + const result = detectReferenceAt(line, line.indexOf('My-Rpc_1')); + assert.strictEqual(result?.startColumn, line.indexOf('rpc://'), 'starts at rpc://'); + assert.strictEqual(result?.endColumn, line.indexOf('My-Rpc_1') + 'My-Rpc_1'.length, 'ends after the name'); + }); + + test('Does not match one character past the end of the token (half-open range)', () => { + const line = 'rpc://Vendors next'; + const end = line.indexOf('Vendors') + 'Vendors'.length; + assert.strictEqual(detectReferenceAt(line, end), undefined, 'column === endColumn is outside the range'); + }); + + test('Detects a function call and excludes the opening parenthesis from the range', () => { + const line = '{{ getTimeActivityBody(parameters) }}'; + const start = line.indexOf('getTimeActivityBody'); + assert.deepStrictEqual(detectReferenceAt(line, start + 3), { + kind: 'function', + name: 'getTimeActivityBody', + startColumn: start, + endColumn: start + 'getTimeActivityBody'.length, + }); + }); + + test('iml-templates scope ignores function calls outside {{ }}', () => { + const line = '"url": "helper(x)", "expr": "{{ helper(x) }}"'; + const outside = line.indexOf('helper'); + const inside = line.lastIndexOf('helper'); + assert.strictEqual( + detectReferenceAt(line, outside, { functionScope: 'iml-templates' }), + undefined, + 'outside template is ignored', + ); + assert.strictEqual( + detectReferenceAt(line, inside, { functionScope: 'iml-templates' })?.name, + 'helper', + 'inside template is detected', + ); + }); + + test('anywhere scope still detects function calls outside {{ }}', () => { + const line = 'const x = helper(1);'; + const start = line.indexOf('helper'); + assert.strictEqual(detectReferenceAt(line, start, { functionScope: 'anywhere' })?.name, 'helper'); + }); + + test('Returns undefined when the cursor is not over any reference', () => { + assert.strictEqual(detectReferenceAt('"label": "Plain text value"', 5), undefined); + }); + + test('rpc references take precedence over the function-call pattern on the same line', () => { + const line = 'rpc://Vendors and helper('; + const result = detectReferenceAt(line, 2); + assert.strictEqual(result?.kind, 'rpc', 'cursor inside rpc token resolves to rpc'); + assert.strictEqual(result?.name, 'Vendors'); + }); +}); + +suite('component-reference parseOnlineAppContext()', () => { + test('Parses app name and version from an online temp path', () => { + const fsPath = '/tmp/abc/apps-sdk/sdk/apps/my-app/2/rpcs/getVendors/api.imljson'; + assert.deepStrictEqual(parseOnlineAppContext(fsPath), { appName: 'my-app', version: 2 }); + }); + + test('Handles Windows backslash separators', () => { + const fsPath = 'C:\\Temp\\abc\\apps-sdk\\sdk\\apps\\my-app\\3\\functions\\fn\\code.js'; + assert.deepStrictEqual(parseOnlineAppContext(fsPath), { appName: 'my-app', version: 3 }); + }); + + test('Returns undefined for a non-app path', () => { + assert.strictEqual(parseOnlineAppContext('/home/user/project/src/index.ts'), undefined); + }); + + test('Returns undefined when the version segment is not numeric', () => { + const fsPath = '/tmp/abc/apps-sdk/sdk/apps/my-app/base.imljson'; + assert.strictEqual(parseOnlineAppContext(fsPath), undefined); + }); +}); diff --git a/src/libs/component-reference.ts b/src/libs/component-reference.ts new file mode 100644 index 00000000..597488c7 --- /dev/null +++ b/src/libs/component-reference.ts @@ -0,0 +1,160 @@ +import type { ComponentCodeType } from '../local-development/types/code-type.types'; + +/** + * Pure helpers for detecting references to other app components inside app code, and for + * resolving the current app context from an online temp-file path. + * + * Two kinds of references are recognized: + * - RPC references written as `rpc://` (in `imljson` string values), + * - custom IML function calls written as `(` (in `imljson` `{{ ... }}` templates and in + * function `.js` code). + * + * Detection here is intentionally syntactic only. Deciding whether a detected token is a *real*, + * openable component is the caller's job: it matches the token against the current app's known + * RPC / custom-function names. This allow-list is exactly what filters out built-in IML functions + * such as `length()` or `join()` (they are not in the app's custom-function list). + */ + +export type ReferenceKind = 'rpc' | 'function'; + +/** + * Where function-call detection is allowed on a line: + * - `anywhere` — whole line (used for `.js` function code files), + * - `iml-templates` — only inside `{{ ... }}` regions (used for `imljson`). + */ +export type FunctionDetectScope = 'anywhere' | 'iml-templates'; + +export interface DetectReferenceOptions { + functionScope?: FunctionDetectScope; +} + +export interface DetectedReference { + kind: ReferenceKind; + /** The referenced component name exactly as written in code (RPC remote name or function name). */ + name: string; + /** 0-based column of the first character of the hover range. */ + startColumn: number; + /** 0-based column just past the last character of the hover range (half-open: `[start, end)`). */ + endColumn: number; +} + +/** + * The single "main" code file each reference kind opens, in the two shapes this feature needs: + * - `codeType`: the key under a component's `codeFiles` in `makecomapp.json` (local-development mode). + * - `apiCodeType`/`language`: the online-mode tree `Code` node's id and VS Code language id, mirroring + * the `rpc`/`function` cases hardcoded in `AppsProvider.js`'s `getChildren()`. + * Centralized here so the local-dev resolver and the online open command share one mapping instead of + * each encoding it separately. Note this only covers the 2 component types `rpc://` / IML-function + * references can point to — it does not attempt to unify with the per-component-type code definitions + * in `component-code-def.ts` / `AppsProvider.js`, which cover every component type and are out of scope. + */ +export const REFERENCE_CODE_DEF: Record< + ReferenceKind, + { codeType: ComponentCodeType; apiCodeType: string; language: string } +> = { + rpc: { codeType: 'communication', apiCodeType: 'api', language: 'imljson' }, + function: { codeType: 'code', apiCodeType: 'code', language: 'js' }, +}; + +// `rpc://Name` - Make RPC names use letters, digits, underscores and hyphens. +const RPC_REFERENCE_REGEX = /rpc:\/\/([A-Za-z0-9_-]+)/g; +// `identifier(` - a function call. Identifier follows JS rules (functions are JS-named in Make). +const FUNCTION_CALL_REGEX = /([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g; +// Non-greedy `{{ ... }}` regions on a single line (IML templates in imljson strings). +const IML_TEMPLATE_REGEX = /\{\{[\s\S]*?\}\}/g; + +/** + * Finds a component reference whose hover range contains the given 0-based `column` on a single + * line of text. RPC references take precedence over function calls. Returns `undefined` when the + * cursor is not over a recognizable reference. + * + * Ranges are half-open `[startColumn, endColumn)`. For RPC the range spans the whole `rpc://Name` + * token; for a function call it spans only the identifier (not the `(`). + * + * @param options.functionScope Defaults to `anywhere`. Use `iml-templates` for imljson so bare + * `foo(` outside `{{ }}` (e.g. in plain JSON) does not produce a function hover. + */ +export function detectReferenceAt( + line: string, + column: number, + options?: DetectReferenceOptions, +): DetectedReference | undefined { + RPC_REFERENCE_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = RPC_REFERENCE_REGEX.exec(line)) !== null) { + const start = match.index; + const end = match.index + match[0].length; + if (column >= start && column < end) { + return { kind: 'rpc', name: match[1], startColumn: start, endColumn: end }; + } + } + + const functionScope = options?.functionScope ?? 'anywhere'; + const templateRegions = + functionScope === 'iml-templates' ? findImlTemplateRegions(line) : undefined; + + FUNCTION_CALL_REGEX.lastIndex = 0; + while ((match = FUNCTION_CALL_REGEX.exec(line)) !== null) { + const start = match.index; + // Hover range is the identifier only, excluding any whitespace and the `(`. + const end = match.index + match[1].length; + if (column < start || column >= end) { + continue; + } + if (templateRegions && !isInsideAnyRegion(start, end, templateRegions)) { + continue; + } + return { kind: 'function', name: match[1], startColumn: start, endColumn: end }; + } + + return undefined; +} + +/** Returns half-open `[start, end)` column ranges for each `{{ ... }}` on the line. */ +function findImlTemplateRegions(line: string): { start: number; end: number }[] { + const regions: { start: number; end: number }[] = []; + IML_TEMPLATE_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = IML_TEMPLATE_REGEX.exec(line)) !== null) { + regions.push({ start: match.index, end: match.index + match[0].length }); + } + return regions; +} + +function isInsideAnyRegion( + start: number, + end: number, + regions: { start: number; end: number }[], +): boolean { + return regions.some((region) => start >= region.start && end <= region.end); +} + +/** + * Extracts the app name and major version from an online temp-file path (the files this extension + * downloads while editing a cloud app). Mirrors `CoreCommands.keepProviders` / + * `ImljsonSchemaAssociations.parseAppAndVersion`: after the `apps-sdk` temp segment the path is + * `/sdk/apps/////.` (`sdk` is a fixed crumb + * that is shifted off so `appName` lands at index 2 and `version` at index 3). + * + * Returns `undefined` when the path is not an online app code file or the version is not numeric + * (e.g. unversioned connection/webhook code or app-level files), in which case an RPC/function + * reference cannot be resolved. + */ +export function parseOnlineAppContext(fsPath: string): { appName: string; version: number } | undefined { + const tempSegmentIndex = fsPath.lastIndexOf('apps-sdk'); + if (tempSegmentIndex === -1) { + return undefined; + } + const right = fsPath.slice(tempSegmentIndex + 'apps-sdk'.length).replace(/\\/g, '/'); + const crumbs = right.split('/'); + // Drop the fixed `sdk` crumb so indices match keepProviders / parseAppAndVersion. + if (crumbs[1] === 'sdk') { + crumbs.shift(); + } + const appName = crumbs[2]; + const version = Number(crumbs[3]); + if (!appName || Number.isNaN(version)) { + return undefined; + } + return { appName, version }; +} diff --git a/src/local-development/makecomappjson.ts b/src/local-development/makecomappjson.ts index d9e5e975..335cf81c 100644 --- a/src/local-development/makecomappjson.ts +++ b/src/local-development/makecomappjson.ts @@ -48,8 +48,15 @@ export function getMakecomappRootDir(anyProjectPath: vscode.Uri): vscode.Uri { /** * Gets makecomapp.json content from the nearest parent dir, where makecomapp.json is located. + * + * @param options.readOnly When `true`, skip writing a migrated file back to disk (see below). + * Use this for read-only callers (e.g. a hover provider) where silently mutating a git-tracked + * file as a side effect of, say, hovering the mouse over some code would be surprising. */ -export async function getMakecomappJson(anyProjectPath: vscode.Uri): Promise { +export async function getMakecomappJson( + anyProjectPath: vscode.Uri, + options?: { readOnly?: boolean }, +): Promise { const makecomappRootdir = getMakecomappRootDir(anyProjectPath); const makecomappJsonPath = vscode.Uri.joinPath(makecomappRootdir, MAKECOMAPP_FILENAME); let makecomappJsonRaw: string; @@ -79,7 +86,7 @@ export async function getMakecomappJson(anyProjectPath: vscode.Uri): Promise }>(); + private static readonly ONLINE_CACHE_TTL_MS = 30_000; + + /** + * Hovered-file directory → local app root fsPath, or `null` when a prior lookup proved this + * directory is not under a `makecomapp.json`. Avoids re-walking the filesystem (via + * `getMakecomappRootDir`) on every hover over `foo(` in unrelated `.js` files. + * + * Negative (`null`) entries never go stale on their own — call `clearLocalAppRootCache()` + * when a `makecomapp.json` is created or deleted anywhere in the workspace (wired up in + * `extension.ts` via a `FileSystemWatcher`), otherwise a directory hovered before an app was + * cloned into it would stay "not a Make project" until the window reloads. + */ + private readonly localAppRootCache = new Map(); + + constructor(private readonly authorization: string, private readonly environment: Environment) {} + + /** Clears the local-app-root cache. Call when a `makecomapp.json` is created or deleted. */ + clearLocalAppRootCache(): void { + this.localAppRootCache.clear(); + } + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + ): Promise { + const isOnline = isFileBelongingToExtension(document.fileName); + // imljson: only treat `foo(` inside `{{ }}` as a function reference. javascript: whole file. + const functionScope = document.languageId === 'imljson' ? 'iml-templates' : 'anywhere'; + const line = document.lineAt(position.line).text; + const reference = detectReferenceAt(line, position.character, { functionScope }); + if (!reference) { + return undefined; + } + + const target = isOnline + ? await this.resolveOnline(document, reference, token) + : await this.resolveLocal(document.uri, reference, token); + + if (!target || token.isCancellationRequested) { + return undefined; + } + + return this.buildHover(reference, target, position); + } + + /** + * Returns the (possibly cached) component summary for an online app. Used by the open command + * so a click after hover does not repeat the network fetch. + */ + getComponentsForApp(appName: string, version: number): Promise { + return this.getOnlineComponents(appName, version); + } + + /** Resolves an online (cloud) reference into a command target, or `undefined` if unknown. */ + private async resolveOnline( + document: vscode.TextDocument, + reference: DetectedReference, + token: vscode.CancellationToken, + ): Promise { + const context = parseOnlineAppContext(document.fileName); + if (!context) { + return undefined; + } + + let components: AppComponentsSummaryResult['components']; + try { + ({ components } = await this.getOnlineComponents(context.appName, context.version)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log('warn', `Component reference hover: failed to load components of ${context.appName}: ${message}`); + return undefined; + } + // The user may have moved the mouse away while the (possibly uncached) API call was in flight. + if (token.isCancellationRequested) { + return undefined; + } + + const exists = components.some( + (component) => component.supertype === reference.kind && component.name === reference.name, + ); + if (!exists) { + return undefined; + } + + return { + mode: 'online', + appName: context.appName, + appVersion: context.version, + supertype: reference.kind, + componentName: reference.name, + }; + } + + /** Resolves a local-development reference to the on-disk code file URI, or `undefined`. */ + private async resolveLocal( + documentUri: vscode.Uri, + reference: DetectedReference, + token: vscode.CancellationToken, + ): Promise { + const appRootFsPath = this.findLocalAppRoot(documentUri); + if (!appRootFsPath) { + return undefined; + } + + let makecomappJson: Awaited>; + try { + // Read-only: a hover must never have the side effect of migrating-and-saving + // makecomapp.json (that would silently dirty a git-tracked file on mouse-over). + makecomappJson = await getMakecomappJson(documentUri, { readOnly: true }); + } catch { + // Treat as miss: either a race (cache said there is a root but the file became + // unreadable) or a genuine data problem (e.g. a malformed makecomapp.json). Either way + // there is nothing openable to hover, so fail silently rather than showing a hover error. + return undefined; + } + if (token.isCancellationRequested) { + return undefined; + } + + const components = makecomappJson.components[reference.kind]; + if (!components) { + return undefined; + } + + // The token may be a local id directly, or a remote name that maps to a local id via an origin. + let localId: string | undefined = components[reference.name] ? reference.name : undefined; + if (!localId) { + for (const origin of makecomappJson.origins ?? []) { + const mapped = new ComponentIdMappingHelper(makecomappJson, origin).getLocalId( + reference.kind, + reference.name, + ); + if (mapped && components[mapped]) { + localId = mapped; + break; + } + } + } + if (!localId) { + return undefined; + } + + const relativePath = components[localId]?.codeFiles?.[REFERENCE_CODE_DEF[reference.kind].codeType]; + if (!relativePath) { + return undefined; + } + + const root = vscode.Uri.file(appRootFsPath); + return { mode: 'local', fileUri: vscode.Uri.joinPath(root, relativePath).toString() }; + } + + /** + * Resolves the `makecomapp.json` root directory for the hovered file's directory, caching hits + * and misses so unrelated workspace `.js` hovers do not repeat the walk. Delegates to + * `getMakecomappRootDir` (the same helper every other local-dev flow uses) rather than + * re-implementing the upward walk, so this also correctly stays within the workspace boundary. + */ + private findLocalAppRoot(documentUri: vscode.Uri): string | null { + const cacheKey = path.dirname(documentUri.fsPath); + const cached = this.localAppRootCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + let root: string | null; + try { + root = getMakecomappRootDir(documentUri).fsPath; + } catch { + root = null; + } + this.localAppRootCache.set(cacheKey, root); + return root; + } + + private getOnlineComponents(appName: string, version: number): Promise { + const key = `${appName}@${version}`; + const cached = this.onlineCache.get(key); + if (cached && Date.now() - cached.at < ComponentReferenceHoverProvider.ONLINE_CACHE_TTL_MS) { + return cached.result; + } + const result = fetchAppComponentsSummary({ + baseUrl: this.environment.baseUrl, + authorization: this.authorization, + appName, + appVersion: version, + }); + this.onlineCache.set(key, { at: Date.now(), result }); + // Do not keep a rejected promise in the cache — the next hover should retry. + result.catch(() => { + const current = this.onlineCache.get(key); + if (current?.result === result) { + this.onlineCache.delete(key); + } + }); + return result; + } + + private buildHover( + reference: DetectedReference, + target: OpenReferencedComponentTarget, + position: vscode.Position, + ): vscode.Hover { + const range = new vscode.Range(position.line, reference.startColumn, position.line, reference.endColumn); + const args = encodeURIComponent(JSON.stringify([target])); + const label = reference.kind === 'rpc' ? 'RPC' : 'function'; + const markdown = new vscode.MarkdownString( + `$(go-to-file) [Open ${label} \`${reference.name}\`](command:apps-sdk.open-referenced-component?${args})`, + ); + // Trust only this specific command, not every command URI the markdown could contain. + markdown.isTrusted = { enabledCommands: ['apps-sdk.open-referenced-component'] }; + markdown.supportThemeIcons = true; + return new vscode.Hover(markdown, range); + } +}