Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -536,6 +541,12 @@
]
},
"menus": {
"commandPalette": [
{
"command": "apps-sdk.open-referenced-component",
"when": "false"
}
],
"editor/title": [
{
"when": "resourceFilename == interface.imljson",
Expand Down
110 changes: 109 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;

Expand Down Expand Up @@ -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
*/
Expand Down
94 changes: 94 additions & 0 deletions src/libs/component-reference.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading