From 7cfe3968c0f3d581252731a3a4d5bf73384699fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Be=C5=88ov=C3=A1?= <116371955+KatBen-Make@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:04:21 -0600 Subject: [PATCH 1/4] feat: hover-to-open navigation for component references Add a hover popup over RPC references (rpc://Name) and custom IML function calls in app code. Clicking the popup link opens the referenced component's code file and reveals it in the sidebar. Works in both online mode (cloud temp files, opened via apps-sdk.load-source and revealed in the apps tree) and local-development mode (resolved via makecomapp.json + idMapping, opened from disk and revealed in the explorer). Built on the shared app-component-search helper (fetchAppComponentsSummary, buildComponentTreeItem). Token detection and online-path parsing live in a pure, unit-tested module (src/libs/component-reference.ts). Co-authored-by: Cursor --- src/extension.ts | 58 +++++- src/libs/component-reference.test.ts | 71 ++++++++ src/libs/component-reference.ts | 93 ++++++++++ .../ComponentReferenceHoverProvider.ts | 165 ++++++++++++++++++ 4 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/libs/component-reference.test.ts create mode 100644 src/libs/component-reference.ts create mode 100644 src/providers/ComponentReferenceHoverProvider.ts diff --git a/src/extension.ts b/src/extension.ts index 472e3653..b326d7b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,8 @@ import { import { AppsProvider } from './providers/AppsProvider'; import { OpensourceProvider } from './providers/OpensourceProvider'; import ImljsonHoverProvider = require('./providers/ImljsonHoverProvider'); +import { ComponentReferenceHoverProvider } from './providers/ComponentReferenceHoverProvider'; +import Code from './tree/Code'; import RpcCommands = require('./commands/RpcCommands'); import { EndpointCommands } from './commands/EndpointCommands'; import ModuleCommands = require('./commands/ModuleCommands'); @@ -42,7 +44,7 @@ import { type AppComponentType, AppComponentTypes } from './types/app-component- 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'; let client: vscodeLanguageclient.LanguageClient; @@ -258,6 +260,60 @@ 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. + // Captured as const so the values stay narrowed (non-undefined) inside the command closure below. + const environment = _environment; + vscode.languages.registerHoverProvider( + [ + { language: 'imljson', scheme: 'file' }, + { language: 'javascript', scheme: 'file' }, + ], + new ComponentReferenceHoverProvider(_authorization, environment), + ); + + vscode.commands.registerCommand( + 'apps-sdk.open-referenced-component', + catchError('Open referenced component', async (target) => { + if (!target) { + return; + } + + // Local-development mode: the code file is already resolved to an on-disk URI. + 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: rebuild the tree nodes, open the code via the shared loader, and reveal it. + const appNode = { + id: `${target.appName}@${target.appVersion}`, + name: target.appName, + version: target.appVersion, + parent: undefined, + }; + const { components } = await appsProvider.getAppComponentsSummary(appNode); + const summary = components.find( + (component: any) => 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. + const codeName = target.supertype === 'rpc' ? 'api' : 'code'; + const language = target.supertype === 'rpc' ? 'imljson' : 'js'; + const apiPath = pathDeterminer(environment.version, target.supertype); + const codeNode = new (Code as any)(codeName, codeName, item, language, apiPath, false, null, undefined); + + await vscode.commands.executeCommand('apps-sdk.load-source', codeNode); + await appsTreeView.reveal(item, { select: true, focus: true, expand: true }); + }), + ); + /** * Registering commands */ diff --git a/src/libs/component-reference.test.ts b/src/libs/component-reference.test.ts new file mode 100644 index 00000000..b9316f38 --- /dev/null +++ b/src/libs/component-reference.test.ts @@ -0,0 +1,71 @@ +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('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('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 a v2 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('Parses app name and version from a v1 temp path (no sdk segment)', () => { + const fsPath = '/tmp/abc/apps-sdk/app/my-app/1/rpc/getVendors/api.imljson'; + assert.deepStrictEqual(parseOnlineAppContext(fsPath), { appName: 'my-app', version: 1 }); + }); + + 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..b83fdb3f --- /dev/null +++ b/src/libs/component-reference.ts @@ -0,0 +1,93 @@ +/** + * 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. That 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'; + +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. */ + endColumn: number; +} + +// `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; + +/** + * 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. + * + * For RPC the hover range spans the whole `rpc://Name` token; for a function call it spans only the + * identifier (not the `(`). + */ +export function detectReferenceAt(line: string, column: number): 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 }; + } + } + + 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) { + return { kind: 'function', name: match[1], startColumn: start, endColumn: end }; + } + } + + return undefined; +} + +/** + * Extracts the app name and major version from an online temp-file path (the files this extension + * downloads while editing a cloud app). Mirrors the crumb logic in + * `CoreCommands.keepProviders`: after the `apps-sdk` temp segment the path is + * `[/sdk]//////.`, where the + * leading `sdk` segment is present only on API v2. + * + * 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 leading empty crumb so v1 (`/app/...`) and v2 (`/sdk/apps/...`) align on the same indices. + 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/providers/ComponentReferenceHoverProvider.ts b/src/providers/ComponentReferenceHoverProvider.ts new file mode 100644 index 00000000..5fe6261e --- /dev/null +++ b/src/providers/ComponentReferenceHoverProvider.ts @@ -0,0 +1,165 @@ +import * as vscode from 'vscode'; +import { fetchAppComponentsSummary, type AppComponentsSummaryResult } from '../libs/app-component-search'; +import { detectReferenceAt, parseOnlineAppContext, type DetectedReference } from '../libs/component-reference'; +import { isFileBelongingToExtension } from '../temp-dir'; +import { getMakecomappJson, getMakecomappRootDir } from '../local-development/makecomappjson'; +import { ComponentIdMappingHelper } from '../local-development/helpers/component-id-mapping-helper'; +import type { Environment } from '../types/environment.types'; +import { log } from '../output-channel'; + +/** The component reference type maps 1:1 to a local component type and an `Item.supertype`. */ +const REFERENCE_CODE_TYPE = { rpc: 'communication', function: 'code' } as const; + +/** Payload handed to `apps-sdk.open-referenced-component` when the hover link is clicked. */ +export type OpenReferencedComponentTarget = + | { mode: 'online'; appName: string; appVersion: number; supertype: 'rpc' | 'function'; componentName: string } + | { mode: 'local'; fileUri: string }; + +/** + * Shows a hover popup with an "Open" command-link over component references (`rpc://Name` and + * custom IML function calls) in app code. Works for both online mode (cloud temp files) and + * local-development mode (files under a `makecomapp.json`). Resolution is what gates the hover: + * a popup only appears when the referenced component actually exists in the current app, which also + * keeps built-in IML functions from being offered. + */ +export class ComponentReferenceHoverProvider implements vscode.HoverProvider { + /** Caches the online component fetch per `appName@version` to avoid an API call on every hover. */ + private readonly onlineCache = new Map }>(); + private static readonly ONLINE_CACHE_TTL_MS = 30_000; + + constructor(private readonly authorization: string, private readonly environment: Environment) {} + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + const line = document.lineAt(position.line).text; + const reference = detectReferenceAt(line, position.character); + if (!reference) { + return undefined; + } + + const target = isFileBelongingToExtension(document.fileName) + ? await this.resolveOnline(document, reference) + : await this.resolveLocal(document.uri, reference); + + if (!target) { + return undefined; + } + + return this.buildHover(reference, target, position); + } + + /** Resolves an online (cloud) reference into a command target, or `undefined` if unknown. */ + private async resolveOnline( + document: vscode.TextDocument, + reference: DetectedReference, + ): 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: any) { + log('warn', `Component reference hover: failed to load components of ${context.appName}: ${err.message}`); + 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, + ): Promise { + let root: vscode.Uri; + let makecomappJson: Awaited>; + try { + root = getMakecomappRootDir(documentUri); + makecomappJson = await getMakecomappJson(documentUri); + } catch { + // Not inside a local app project (e.g. a stray .js file) - nothing to resolve. + 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_TYPE[reference.kind]]; + if (!relativePath) { + return undefined; + } + + return { mode: 'local', fileUri: vscode.Uri.joinPath(root, relativePath).toString() }; + } + + 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, + environment: this.environment, + appName, + appVersion: version, + }); + this.onlineCache.set(key, { at: Date.now(), result }); + 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})`, + ); + markdown.isTrusted = true; + markdown.supportThemeIcons = true; + return new vscode.Hover(markdown, range); + } +} From e418d2e0635958acb59ca840de390e807cf7278d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Be=C5=88ov=C3=A1?= <116371955+KatBen-Make@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:08:12 -0600 Subject: [PATCH 2/4] fix: address self-review feedback on hover-to-open navigation Apply the dev-conventions distilled from prior PR reviews (#361/#362/#366): - Type the open-referenced-component command target instead of using an untyped parameter. - Fetch the live app tree node and component summary in parallel, wrapped in a progress notification, instead of a sequential synthetic-node path. - Make reveal failures non-fatal and log them instead of throwing after the file has already opened successfully. - Restrict imljson function-reference detection to {{ }} template regions and use half-open ranges so trailing characters are not matched. - Fix the online-cache to evict on rejection instead of replaying a failed fetch, and cache negative local makecomapp.json lookups per directory. - Update unit tests to cover the new detection and caching behavior. Co-authored-by: Cursor --- package.json | 11 +++ src/extension.ts | 74 ++++++++++---- src/libs/component-reference.test.ts | 35 +++++-- src/libs/component-reference.ts | 71 +++++++++++--- .../ComponentReferenceHoverProvider.ts | 96 +++++++++++++++++-- 5 files changed, 243 insertions(+), 44 deletions(-) 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 b326d7b5..520d6ff1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,7 +26,10 @@ import { import { AppsProvider } from './providers/AppsProvider'; import { OpensourceProvider } from './providers/OpensourceProvider'; import ImljsonHoverProvider = require('./providers/ImljsonHoverProvider'); -import { ComponentReferenceHoverProvider } from './providers/ComponentReferenceHoverProvider'; +import { + ComponentReferenceHoverProvider, + type OpenReferencedComponentTarget, +} from './providers/ComponentReferenceHoverProvider'; import Code from './tree/Code'; import RpcCommands = require('./commands/RpcCommands'); import { EndpointCommands } from './commands/EndpointCommands'; @@ -45,6 +48,7 @@ import { deleteLocalComponent } from './local-development/delete-local-component import { catchError } from './error-handling'; import { camelToKebab } from './utils/camel-to-kebab'; import { contextGuard, pathDeterminer } from './Core'; +import type { AppComponentSummary } from './libs/app-component-search'; let client: vscodeLanguageclient.LanguageClient; @@ -261,24 +265,24 @@ export async function activate(context: vscode.ExtensionContext) { })); // Hover-to-open for component references (rpc://Name and custom IML function calls) in app code. - // Captured as const so the values stay narrowed (non-undefined) inside the command closure below. - const environment = _environment; + const componentReferenceHoverProvider = new ComponentReferenceHoverProvider(_authorization, _environment); vscode.languages.registerHoverProvider( [ { language: 'imljson', scheme: 'file' }, { language: 'javascript', scheme: 'file' }, ], - new ComponentReferenceHoverProvider(_authorization, environment), + componentReferenceHoverProvider, ); vscode.commands.registerCommand( 'apps-sdk.open-referenced-component', - catchError('Open referenced component', async (target) => { + catchError('Open referenced component', async (target: OpenReferencedComponentTarget) => { if (!target) { return; } - // Local-development mode: the code file is already resolved to an on-disk URI. + // 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 }); @@ -286,16 +290,40 @@ export async function activate(context: vscode.ExtensionContext) { return; } - // Online mode: rebuild the tree nodes, open the code via the shared loader, and reveal it. - const appNode = { - id: `${target.appName}@${target.appVersion}`, - name: target.appName, - version: target.appVersion, - parent: undefined, - }; - const { components } = await appsProvider.getAppComponentsSummary(appNode); - const summary = components.find( - (component: any) => component.supertype === target.supertype && component.name === target.componentName, + // 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([ + appsProvider.getChildren(), + // 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.`); @@ -304,13 +332,21 @@ export async function activate(context: vscode.ExtensionContext) { const item = appsProvider.buildComponentTreeItem(appNode, summary); // RPC code lives in the "api" (imljson) file; function code in the "code" (js) file. + // (This is the online tree Code-node id, unrelated to the local-dev `codeFiles` key names + // — e.g. `REFERENCE_CODE_TYPE` in ComponentReferenceHoverProvider — which use `communication`.) const codeName = target.supertype === 'rpc' ? 'api' : 'code'; const language = target.supertype === 'rpc' ? 'imljson' : 'js'; - const apiPath = pathDeterminer(environment.version, target.supertype); - const codeNode = new (Code as any)(codeName, codeName, item, language, apiPath, false, null, undefined); + 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); - await appsTreeView.reveal(item, { select: true, focus: true, expand: true }); + 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}`); + } }), ); diff --git a/src/libs/component-reference.test.ts b/src/libs/component-reference.test.ts index b9316f38..eb10a2fb 100644 --- a/src/libs/component-reference.test.ts +++ b/src/libs/component-reference.test.ts @@ -21,6 +21,12 @@ suite('component-reference detectReferenceAt()', () => { 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'); @@ -32,6 +38,28 @@ suite('component-reference detectReferenceAt()', () => { }); }); + 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); }); @@ -45,16 +73,11 @@ suite('component-reference detectReferenceAt()', () => { }); suite('component-reference parseOnlineAppContext()', () => { - test('Parses app name and version from a v2 temp path', () => { + 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('Parses app name and version from a v1 temp path (no sdk segment)', () => { - const fsPath = '/tmp/abc/apps-sdk/app/my-app/1/rpc/getVendors/api.imljson'; - assert.deepStrictEqual(parseOnlineAppContext(fsPath), { appName: 'my-app', version: 1 }); - }); - 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 }); diff --git a/src/libs/component-reference.ts b/src/libs/component-reference.ts index b83fdb3f..c4f7db36 100644 --- a/src/libs/component-reference.ts +++ b/src/libs/component-reference.ts @@ -15,13 +15,24 @@ 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. */ + /** 0-based column just past the last character of the hover range (half-open: `[start, end)`). */ endColumn: number; } @@ -29,45 +40,81 @@ export interface DetectedReference { 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. * - * For RPC the hover range spans the whole `rpc://Name` token; for a function call it spans only the - * identifier (not the `(`). + * 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): DetectedReference | undefined { +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) { + 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) { - return { kind: 'function', name: match[1], startColumn: start, endColumn: end }; + 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 the crumb logic in - * `CoreCommands.keepProviders`: after the `apps-sdk` temp segment the path is - * `[/sdk]//////.`, where the - * leading `sdk` segment is present only on API v2. + * 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 @@ -80,7 +127,7 @@ export function parseOnlineAppContext(fsPath: string): { appName: string; versio } const right = fsPath.slice(tempSegmentIndex + 'apps-sdk'.length).replace(/\\/g, '/'); const crumbs = right.split('/'); - // Drop the leading empty crumb so v1 (`/app/...`) and v2 (`/sdk/apps/...`) align on the same indices. + // Drop the fixed `sdk` crumb so indices match keepProviders / parseAppAndVersion. if (crumbs[1] === 'sdk') { crumbs.shift(); } diff --git a/src/providers/ComponentReferenceHoverProvider.ts b/src/providers/ComponentReferenceHoverProvider.ts index 5fe6261e..111964e3 100644 --- a/src/providers/ComponentReferenceHoverProvider.ts +++ b/src/providers/ComponentReferenceHoverProvider.ts @@ -1,8 +1,14 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import * as vscode from 'vscode'; -import { fetchAppComponentsSummary, type AppComponentsSummaryResult } from '../libs/app-component-search'; +import { + fetchAppComponentsSummary, + type AppComponentsSummaryResult, +} from '../libs/app-component-search'; import { detectReferenceAt, parseOnlineAppContext, type DetectedReference } from '../libs/component-reference'; import { isFileBelongingToExtension } from '../temp-dir'; import { getMakecomappJson, getMakecomappRootDir } from '../local-development/makecomappjson'; +import { MAKECOMAPP_FILENAME } from '../local-development/consts'; import { ComponentIdMappingHelper } from '../local-development/helpers/component-id-mapping-helper'; import type { Environment } from '../types/environment.types'; import { log } from '../output-channel'; @@ -27,19 +33,29 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { private readonly onlineCache = new Map }>(); private static readonly ONLINE_CACHE_TTL_MS = 30_000; + /** + * Directory → local app root fsPath, or `null` when a prior walk proved this directory (and the + * path upward from a failed resolve) is not under a `makecomapp.json`. Avoids re-walking the + * filesystem on every hover over `foo(` in unrelated `.js` files. + */ + private readonly localAppRootCache = new Map(); + constructor(private readonly authorization: string, private readonly environment: Environment) {} async provideHover( document: vscode.TextDocument, position: vscode.Position, ): 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); + const reference = detectReferenceAt(line, position.character, { functionScope }); if (!reference) { return undefined; } - const target = isFileBelongingToExtension(document.fileName) + const target = isOnline ? await this.resolveOnline(document, reference) : await this.resolveLocal(document.uri, reference); @@ -50,6 +66,14 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { 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, @@ -63,8 +87,9 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { let components: AppComponentsSummaryResult['components']; try { ({ components } = await this.getOnlineComponents(context.appName, context.version)); - } catch (err: any) { - log('warn', `Component reference hover: failed to load components of ${context.appName}: ${err.message}`); + } 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; } @@ -89,13 +114,20 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { documentUri: vscode.Uri, reference: DetectedReference, ): Promise { + const appRootFsPath = this.findLocalAppRoot(documentUri.fsPath); + if (!appRootFsPath) { + return undefined; + } + let root: vscode.Uri; let makecomappJson: Awaited>; try { root = getMakecomappRootDir(documentUri); makecomappJson = await getMakecomappJson(documentUri); } catch { - // Not inside a local app project (e.g. a stray .js file) - nothing to resolve. + // 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; } @@ -130,6 +162,50 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { return { mode: 'local', fileUri: vscode.Uri.joinPath(root, relativePath).toString() }; } + /** + * Walks from the file's directory upward looking for `makecomapp.json`, caching hits and misses + * per directory so unrelated workspace `.js` hovers do not repeat the walk. + */ + private findLocalAppRoot(fileFsPath: string): string | null { + let dir = path.dirname(fileFsPath); + const visited: string[] = []; + + while (true) { + const cached = this.localAppRootCache.get(dir); + if (cached !== undefined) { + if (cached === null) { + for (const visitedDir of visited) { + this.localAppRootCache.set(visitedDir, null); + } + return null; + } + for (const visitedDir of visited) { + this.localAppRootCache.set(visitedDir, cached); + } + return cached; + } + + visited.push(dir); + if (fs.existsSync(path.join(dir, MAKECOMAPP_FILENAME))) { + for (const visitedDir of visited) { + this.localAppRootCache.set(visitedDir, dir); + } + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + for (const visitedDir of visited) { + this.localAppRootCache.set(visitedDir, null); + } + return null; + } + private getOnlineComponents(appName: string, version: number): Promise { const key = `${appName}@${version}`; const cached = this.onlineCache.get(key); @@ -139,11 +215,17 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { const result = fetchAppComponentsSummary({ baseUrl: this.environment.baseUrl, authorization: this.authorization, - environment: this.environment, 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; } From e08db58278187cce65ac5c19da15284136006562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Be=C5=88ov=C3=A1?= <116371955+KatBen-Make@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:13:14 -0600 Subject: [PATCH 3/4] fix: address GitHub Copilot review comments on PR #369 - Tolerate an apps-list fetch failure in open-referenced-component: log and fall back to an empty list instead of failing the whole command, since the file can still be opened from target.appName/appVersion alone. - Restrict MarkdownString.isTrusted to the single command the hover link actually invokes, instead of trusting every command URI. - Grammar: "That allow-list" -> "This allow-list". Co-authored-by: Cursor --- src/extension.ts | 9 ++++++++- src/libs/component-reference.ts | 2 +- src/providers/ComponentReferenceHoverProvider.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 520d6ff1..8af1cc41 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -298,7 +298,14 @@ export async function activate(context: vscode.ExtensionContext) { { location: vscode.ProgressLocation.Notification, title: `Opening ${target.componentName}…` }, async () => { const [apps, componentsResult] = await Promise.all([ - appsProvider.getChildren(), + // 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), ]); diff --git a/src/libs/component-reference.ts b/src/libs/component-reference.ts index c4f7db36..1a501653 100644 --- a/src/libs/component-reference.ts +++ b/src/libs/component-reference.ts @@ -9,7 +9,7 @@ * * 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. That allow-list is exactly what filters out built-in IML functions + * 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). */ diff --git a/src/providers/ComponentReferenceHoverProvider.ts b/src/providers/ComponentReferenceHoverProvider.ts index 111964e3..9384641d 100644 --- a/src/providers/ComponentReferenceHoverProvider.ts +++ b/src/providers/ComponentReferenceHoverProvider.ts @@ -240,7 +240,8 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { const markdown = new vscode.MarkdownString( `$(go-to-file) [Open ${label} \`${reference.name}\`](command:apps-sdk.open-referenced-component?${args})`, ); - markdown.isTrusted = true; + // 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); } From 02fa2ace2602e47ed451330ca5a941a6d4e02383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Be=C5=88ov=C3=A1?= <116371955+KatBen-Make@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:51:26 -0600 Subject: [PATCH 4/4] fix: address Artem's review comments on PR #369 - Critical: getMakecomappJson() migrates-and-saves makecomapp.json when needed, so a plain hover could silently dirty a git-tracked file. Add an opt-in readOnly option and use it from the hover resolver; all other (write-intent) call sites are unaffected. - Respect the CancellationToken VS Code passes into provideHover, bailing out after each await instead of continuing network/FS work once the user has moved the mouse away. - Reuse getMakecomappRootDir() instead of re-implementing the upward makecomapp.json walk with raw fs.existsSync, which also makes the local resolution correctly respect the workspace boundary. - Centralize the rpc/function -> code-file mapping (REFERENCE_CODE_DEF in component-reference.ts) so extension.ts and ComponentReferenceHoverProvider share one source instead of each hardcoding it. - Clear the local-app-root cache when a makecomapp.json is created or deleted anywhere in the workspace (FileSystemWatcher in extension.ts), so hover does not stay dead in a directory after an app is cloned into it. Co-authored-by: Cursor --- src/extension.ts | 17 ++- src/libs/component-reference.ts | 20 ++++ src/local-development/makecomappjson.ts | 11 +- .../ComponentReferenceHoverProvider.ts | 108 +++++++++--------- 4 files changed, 96 insertions(+), 60 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 8af1cc41..aeb85768 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ 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'; @@ -43,6 +44,7 @@ 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'; @@ -273,6 +275,13 @@ export async function activate(context: vscode.ExtensionContext) { ], 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', @@ -339,10 +348,10 @@ export async function activate(context: vscode.ExtensionContext) { const item = appsProvider.buildComponentTreeItem(appNode, summary); // RPC code lives in the "api" (imljson) file; function code in the "code" (js) file. - // (This is the online tree Code-node id, unrelated to the local-dev `codeFiles` key names - // — e.g. `REFERENCE_CODE_TYPE` in ComponentReferenceHoverProvider — which use `communication`.) - const codeName = target.supertype === 'rpc' ? 'api' : 'code'; - const language = target.supertype === 'rpc' ? 'imljson' : 'js'; + // 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); diff --git a/src/libs/component-reference.ts b/src/libs/component-reference.ts index 1a501653..597488c7 100644 --- a/src/libs/component-reference.ts +++ b/src/libs/component-reference.ts @@ -1,3 +1,5 @@ +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. @@ -36,6 +38,24 @@ export interface DetectedReference { 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). 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(); 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. @@ -56,10 +67,10 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { } const target = isOnline - ? await this.resolveOnline(document, reference) - : await this.resolveLocal(document.uri, reference); + ? await this.resolveOnline(document, reference, token) + : await this.resolveLocal(document.uri, reference, token); - if (!target) { + if (!target || token.isCancellationRequested) { return undefined; } @@ -78,6 +89,7 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { private async resolveOnline( document: vscode.TextDocument, reference: DetectedReference, + token: vscode.CancellationToken, ): Promise { const context = parseOnlineAppContext(document.fileName); if (!context) { @@ -92,6 +104,10 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { 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, @@ -113,23 +129,27 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { private async resolveLocal( documentUri: vscode.Uri, reference: DetectedReference, + token: vscode.CancellationToken, ): Promise { - const appRootFsPath = this.findLocalAppRoot(documentUri.fsPath); + const appRootFsPath = this.findLocalAppRoot(documentUri); if (!appRootFsPath) { return undefined; } - let root: vscode.Uri; let makecomappJson: Awaited>; try { - root = getMakecomappRootDir(documentUri); - makecomappJson = await getMakecomappJson(documentUri); + // 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) { @@ -154,56 +174,36 @@ export class ComponentReferenceHoverProvider implements vscode.HoverProvider { return undefined; } - const relativePath = components[localId]?.codeFiles?.[REFERENCE_CODE_TYPE[reference.kind]]; + 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() }; } /** - * Walks from the file's directory upward looking for `makecomapp.json`, caching hits and misses - * per directory so unrelated workspace `.js` hovers do not repeat the walk. + * 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(fileFsPath: string): string | null { - let dir = path.dirname(fileFsPath); - const visited: string[] = []; - - while (true) { - const cached = this.localAppRootCache.get(dir); - if (cached !== undefined) { - if (cached === null) { - for (const visitedDir of visited) { - this.localAppRootCache.set(visitedDir, null); - } - return null; - } - for (const visitedDir of visited) { - this.localAppRootCache.set(visitedDir, cached); - } - return cached; - } - - visited.push(dir); - if (fs.existsSync(path.join(dir, MAKECOMAPP_FILENAME))) { - for (const visitedDir of visited) { - this.localAppRootCache.set(visitedDir, dir); - } - return dir; - } - - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; + private findLocalAppRoot(documentUri: vscode.Uri): string | null { + const cacheKey = path.dirname(documentUri.fsPath); + const cached = this.localAppRootCache.get(cacheKey); + if (cached !== undefined) { + return cached; } - for (const visitedDir of visited) { - this.localAppRootCache.set(visitedDir, null); + let root: string | null; + try { + root = getMakecomappRootDir(documentUri).fsPath; + } catch { + root = null; } - return null; + this.localAppRootCache.set(cacheKey, root); + return root; } private getOnlineComponents(appName: string, version: number): Promise {