Skip to content

Commit b177453

Browse files
committed
feat: when opening API response or DebugLog's don't create temporary files but open them as virtual content instead
1 parent 277ad5c commit b177453

File tree

6 files changed

+131
-45
lines changed

6 files changed

+131
-45
lines changed

packages/vscode-extension/src/commands/execRestApiCommand.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import * as vscode from 'vscode';
22

3-
import { HttpMethod, HttpRequestInfo } from '@vlocode/salesforce';
3+
import { HttpMethod, HttpRequestInfo, HttpResponse } from '@vlocode/salesforce';
44
import { Timer } from '@vlocode/util';
55

66
import { VlocodeCommand } from '../constants';
77
import { vscodeCommand } from '../lib/commandRouter';
88
import { ApiRequestDocumentParser } from '../lib/salesforce/apiRequestDocumentParser';
99
import MetadataCommand from './metadata/metadataCommand';
1010
import { QuickPick } from '../lib/ui/quickPick';
11+
import { container } from '@vlocode/core';
12+
import { VirtualContentProvider } from 'contentProviders/virtualApexContentProvider';
1113

1214
@vscodeCommand(VlocodeCommand.execRestApi)
1315
export default class ExecuteRestApiCommand extends MetadataCommand {
@@ -143,28 +145,56 @@ export default class ExecuteRestApiCommand extends MetadataCommand {
143145
});
144146

145147
const timer = new Timer();
146-
const response = await this.vlocode.withActivity({
147-
progressTitle: `${request.method} ${request.url}...`,
148-
location: vscode.ProgressLocation.Notification,
149-
cancellable: false
150-
}, async () => {
151-
const connection = await this.salesforce.getJsForceConnection();
152-
return connection.request(request);
153-
});
148+
let response: string |undefined = undefined;
149+
try {
150+
await this.vlocode.withActivity({
151+
progressTitle: `${request.method} ${request.url}...`,
152+
location: vscode.ProgressLocation.Notification,
153+
cancellable: false,
154+
propagateExceptions: true
155+
}, async () => {
156+
const connection = await this.salesforce.getJsForceConnection();
157+
response = await connection.request(request);
158+
});
159+
} catch (error) {
160+
void vscode.window.showErrorMessage(`API Request failed with error`);
161+
response = error instanceof Error ? error.message : String(error);
162+
}
163+
154164
this.logger.info(`${request.method} ${request.url} [${timer.stop()}]`);
155165

156-
const responseDocument = await vscode.workspace.openTextDocument({
157-
language: typeof response === 'string' ? undefined : 'json',
158-
content: typeof response === 'string' ? response : JSON.stringify(response, null, 4)
166+
void container.get(VirtualContentProvider).showContent({
167+
title: `${request.method} ${request.url}`,
168+
...this.createResonseDocument(response),
169+
preserveFocus: true,
170+
viewColumn: vscode.ViewColumn.Beside
159171
});
172+
}
160173

161-
if (responseDocument) {
162-
void vscode.window.showTextDocument(responseDocument, {
163-
preview: true,
164-
preserveFocus: true,
165-
viewColumn: vscode.ViewColumn.Beside
166-
});
174+
private createResonseDocument(responseBody: unknown): { content: string, language?: string } {
175+
if (typeof responseBody === 'string') {
176+
const trimmed = responseBody.trim();
177+
// Detect JSON
178+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
179+
try {
180+
const json = JSON.parse(trimmed);
181+
return { content: JSON.stringify(json, null, 4), language: 'json' };
182+
} catch {
183+
// Not valid JSON, fall through
184+
}
185+
}
186+
// Detect XML (very basic)
187+
if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
188+
// Optionally, pretty print XML (not implemented here)
189+
return { content: trimmed, language: 'xml' };
190+
}
191+
// Fallback: return as is
192+
return { content: trimmed };
193+
} else if (typeof responseBody === 'object' && responseBody !== null) {
194+
// If already parsed as object, pretty print as JSON
195+
return { content: JSON.stringify(responseBody, null, 4), language: 'json' };
167196
}
197+
return { content: responseBody ? String(responseBody) : 'No response body', language: 'plaintext' };
168198
}
169199
}
170200

packages/vscode-extension/src/contentProviders/__tests__/salesforceApexContentProvider.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('SalesforceApexContentProvider', () => {
4444
describe('provideTextDocumentContent', () => {
4545
it('should return class body for valid class name URI', async () => {
4646
// Arrange
47-
const uri = vscode.Uri.parse('apex://MyTestClass');
47+
const uri = vscode.Uri.parse('apex://MyTestClass.cls');
4848
const expectedBody = 'public class MyTestClass { }';
4949

5050
mockConnection.tooling.sobject().findOne.mockResolvedValue({
@@ -67,7 +67,7 @@ describe('SalesforceApexContentProvider', () => {
6767

6868
it('should return class body for namespaced class URI', async () => {
6969
// Arrange
70-
const uri = vscode.Uri.parse('apex://MyNamespace/MyTestClass');
70+
const uri = vscode.Uri.parse('apex://MyNamespace/MyTestClass.cls');
7171
const expectedBody = 'public class MyTestClass { }';
7272

7373
mockConnection.tooling.sobject().findOne.mockResolvedValue({
@@ -89,7 +89,7 @@ describe('SalesforceApexContentProvider', () => {
8989

9090
it('should return error comment when class does not exist', async () => {
9191
// Arrange
92-
const uri = vscode.Uri.parse('apex://NonExistentClass');
92+
const uri = vscode.Uri.parse('apex://NonExistentClass.cls');
9393

9494
mockConnection.tooling.sobject().findOne.mockResolvedValue(null);
9595

@@ -121,7 +121,7 @@ describe('SalesforceApexContentProvider', () => {
121121

122122
it('should handle cancellation token', async () => {
123123
// Arrange
124-
const uri = vscode.Uri.parse('apex://MyTestClass');
124+
const uri = vscode.Uri.parse('apex://MyTestClass.cls');
125125
const cancellationToken = {
126126
isCancellationRequested: true,
127127
onCancellationRequested: jest.fn()
@@ -138,7 +138,7 @@ describe('SalesforceApexContentProvider', () => {
138138

139139
it('should use cache for subsequent calls with same URI', async () => {
140140
// Arrange
141-
const uri = vscode.Uri.parse('apex://CachedClass');
141+
const uri = vscode.Uri.parse('apex://CachedClass.cls');
142142
const expectedBody = 'public class CachedClass { }';
143143

144144
mockConnection.tooling.sobject().findOne.mockResolvedValue({
@@ -161,7 +161,7 @@ describe('SalesforceApexContentProvider', () => {
161161
describe('clearCache', () => {
162162
it('should clear cache and refetch content', async () => {
163163
// Arrange
164-
const uri = vscode.Uri.parse('apex://TestClass');
164+
const uri = vscode.Uri.parse('apex://TestClass.cls');
165165
const expectedBody = 'public class TestClass { }';
166166

167167
mockConnection.tooling.sobject().findOne.mockResolvedValue({

packages/vscode-extension/src/contentProviders/salesforceApexContentProvider.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { cache, clearCache } from '@vlocode/util';
2323
* const content = await provider.provideTextDocumentContent(uri);
2424
* ```
2525
*/
26-
@injectable()
26+
@injectable.singleton()
2727
export class SalesforceApexContentProvider implements vscode.TextDocumentContentProvider {
2828

2929
constructor(
@@ -36,10 +36,15 @@ export class SalesforceApexContentProvider implements vscode.TextDocumentContent
3636
*
3737
* @param service - An object that provides a `registerDisposable` method for managing disposables within the extension's lifecycle.
3838
*/
39-
public static register(service: { registerDisposable: (disposable: vscode.Disposable) => void }) {
39+
public static register(service: { registerDisposable: (...disposable: vscode.Disposable[]) => void }) {
4040
const provider = container.get(SalesforceApexContentProvider);
4141
service.registerDisposable(
42-
vscode.workspace.registerTextDocumentContentProvider('apex', provider)
42+
vscode.workspace.registerTextDocumentContentProvider('apex', provider),
43+
vscode.workspace.onDidChangeTextDocument(async (event) => {
44+
if (event.document.uri.scheme === 'apex') {
45+
await vscode.languages.setTextDocumentLanguage(event.document, 'apex');
46+
}
47+
})
4348
);
4449
}
4550

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as vscode from 'vscode';
2+
import { container, injectable } from '@vlocode/core';
3+
4+
@injectable.singleton()
5+
export class VirtualContentProvider implements vscode.TextDocumentContentProvider {
6+
7+
/**
8+
* The URI scheme used by the VirtualContentProvider to identify its content.
9+
*/
10+
public static readonly scheme = 'vlocode';
11+
12+
private contentStore = new Map<string, {
13+
content: string;
14+
language?: string;
15+
}>();
16+
17+
public static register(service: { registerDisposable: (...disposable: vscode.Disposable[]) => void }) {
18+
const provider = container.get(VirtualContentProvider);
19+
service.registerDisposable(
20+
vscode.workspace.registerTextDocumentContentProvider(VirtualContentProvider.scheme, provider),
21+
vscode.workspace.onDidOpenTextDocument((document) => provider.onDidOpenTextDocument(document)),
22+
);
23+
}
24+
25+
private onDidOpenTextDocument(document: vscode.TextDocument) {
26+
if (document.uri.scheme !== VirtualContentProvider.scheme) {
27+
return;
28+
}
29+
30+
const contentDocument = this.contentStore.get(document.uri.toString());
31+
if (contentDocument) {
32+
void vscode.languages.setTextDocumentLanguage(document, contentDocument?.language || 'plaintext');
33+
this.removeContent(document.uri);
34+
}
35+
}
36+
37+
public provideTextDocumentContent(uri: vscode.Uri): string {
38+
const document = this.contentStore.get(uri.toString());
39+
return document ? document.content : `Failed to load: ${uri.toString()}`;
40+
}
41+
42+
public async showContent(options: { title: string, content: string, language?: string } & vscode.TextDocumentShowOptions) {
43+
// use random UUID in URI to avoid conflicts
44+
const uri = this.addContent(options);
45+
const doc = await vscode.workspace.openTextDocument(uri);
46+
return vscode.window.showTextDocument(doc, options);
47+
}
48+
49+
public addContent(document: { title: string, content: string, language?: string }) {
50+
// use random UUID in URI to avoid conflicts
51+
const uri = vscode.Uri.parse(`${VirtualContentProvider.scheme}://${crypto.randomUUID()}/${encodeURIComponent(document.title)}`);
52+
this.contentStore.set(uri.toString(), document);
53+
return uri;
54+
}
55+
56+
public removeContent(uri: vscode.Uri) {
57+
this.contentStore.delete(uri.toString());
58+
}
59+
}

packages/vscode-extension/src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { TestCoverageLensProvider } from './codeLensProviders/testCoverageLensPr
3434
import { PushSourceLensProvider } from './codeLensProviders/pushSourceLensProvider';
3535
import { SfdxConfigManager } from './lib/sfdxConfigManager';
3636
import { SalesforceApexContentProvider } from 'contentProviders/salesforceApexContentProvider';
37+
import { VirtualContentProvider } from 'contentProviders/virtualApexContentProvider';
3738

3839
/**
3940
* Start time of the extension set when the extension is packed by webpack when the entry point is loaded
@@ -204,6 +205,7 @@ class Vlocode {
204205
TestCoverageLensProvider.register(this.service);
205206
PushSourceLensProvider.register(this.service);
206207
SalesforceApexContentProvider.register(this.service);
208+
VirtualContentProvider.register(this.service);
207209

208210
// Watch conditionalContextMenus for changes
209211
ConfigurationManager.onConfigChange(this.service.config, 'conditionalContextMenus',

packages/vscode-extension/src/lib/salesforce/debugLogViewer.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as vscode from 'vscode';
44
import * as fs from 'fs-extra';
55
import { DateTime } from 'luxon';
66
import { DeveloperLog } from '@vlocode/salesforce';
7+
import { container } from '@vlocode/core';
8+
import { VirtualContentProvider } from 'contentProviders/virtualApexContentProvider';
79

810
export class DebugLogViewer {
911

@@ -28,24 +30,12 @@ export class DebugLogViewer {
2830

2931
private async openLog(logBody: string, logFileName: string) {
3032
const formattedLog = this.formatLog(logBody);
31-
32-
if (this.developerLogsPath) {
33-
const fullLogPath = path.join(this.developerLogsPath, logFileName);
34-
await fs.ensureDir(this.developerLogsPath);
35-
await fs.writeFile(fullLogPath, formattedLog);
36-
37-
const debugLog = await vscode.workspace.openTextDocument(fullLogPath);
38-
if (debugLog) {
39-
void vscode.languages.setTextDocumentLanguage(debugLog, 'apexlog');
40-
void vscode.window.showTextDocument(debugLog, { preview: true });
41-
}
42-
} else {
43-
const debugLog = await vscode.workspace.openTextDocument({ content: logBody });
44-
if (debugLog) {
45-
void vscode.languages.setTextDocumentLanguage(debugLog, 'apexlog');
46-
void vscode.window.showTextDocument(debugLog, { preview: true });
47-
}
48-
}
33+
void container.get(VirtualContentProvider).showContent({
34+
title: logFileName,
35+
content: formattedLog,
36+
language: 'apexlog',
37+
preview: true
38+
});
4939
}
5040

5141
private formatExecutionLog(log: string) {

0 commit comments

Comments
 (0)