Skip to content

Commit 585575d

Browse files
authored
Merge pull request #1008 from axewilledge/bi-samples-view
Add sample download functionality and new BI Samples view
2 parents 4898189 + f7d6595 commit 585575d

File tree

13 files changed

+674
-25
lines changed

13 files changed

+674
-25
lines changed

common/config/rush/pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

workspaces/ballerina/ballerina-core/src/rpc-types/common/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import {
3232
FileOrDirRequest,
3333
WorkspaceRootResponse,
3434
ShowErrorMessageRequest,
35-
WorkspaceTypeResponse
35+
WorkspaceTypeResponse,
36+
SampleDownloadRequest
3637
} from "./interfaces";
3738

3839
export interface CommonRPCAPI {
@@ -51,4 +52,5 @@ export interface CommonRPCAPI {
5152
showErrorMessage: (params: ShowErrorMessageRequest) => void;
5253
getCurrentProjectTomlValues: () => Promise<Record<string, any>>;
5354
getWorkspaceType: () => Promise<WorkspaceTypeResponse>;
55+
downloadSelectedSampleFromGithub: (params: SampleDownloadRequest) => Promise<boolean>;
5456
}

workspaces/ballerina/ballerina-core/src/rpc-types/common/interfaces.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,7 @@ export interface PackageTomlValues {
113113
export interface WorkspaceTypeResponse {
114114
type: "SINGLE_PROJECT" | "MULTIPLE_PROJECTS" | "BALLERINA_WORKSPACE" | "VSCODE_WORKSPACE" | "UNKNOWN"
115115
}
116+
117+
export interface SampleDownloadRequest {
118+
zipFileName: string;
119+
}

workspaces/ballerina/ballerina-core/src/rpc-types/common/rpc-type.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import {
3333
FileOrDirRequest,
3434
WorkspaceRootResponse,
3535
ShowErrorMessageRequest,
36-
WorkspaceTypeResponse
36+
WorkspaceTypeResponse,
37+
SampleDownloadRequest
3738
} from "./interfaces";
3839
import { RequestType, NotificationType } from "vscode-messenger-common";
3940

@@ -53,3 +54,4 @@ export const getWorkspaceRoot: RequestType<void, WorkspaceRootResponse> = { meth
5354
export const showErrorMessage: NotificationType<ShowErrorMessageRequest> = { method: `${_preFix}/showErrorMessage` };
5455
export const getCurrentProjectTomlValues: RequestType<void, void> = { method: `${_preFix}/getCurrentProjectTomlValues` };
5556
export const getWorkspaceType: RequestType<void, WorkspaceTypeResponse> = { method: `${_preFix}/getWorkspaceType` };
57+
export const downloadSelectedSampleFromGithub: RequestType<SampleDownloadRequest, boolean> = { method: `${_preFix}/downloadSelectedSampleFromGithub` };

workspaces/ballerina/ballerina-core/src/state-machine-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export enum MACHINE_VIEW {
9999
AIAgentDesigner = "AI Agent Designer",
100100
AIChatAgentWizard = "AI Chat Agent Wizard",
101101
ResolveMissingDependencies = "Resolve Missing Dependencies",
102-
ServiceFunctionForm = "Service Function Form"
102+
ServiceFunctionForm = "Service Function Form",
103+
BISamplesView = "BI Samples View"
103104
}
104105

105106
export interface MachineEvent {

workspaces/ballerina/ballerina-extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,7 @@
12141214
"portfinder": "^1.0.32",
12151215
"source-map-support": "^0.5.21",
12161216
"toml": "^3.0.0",
1217+
"unzipper": "~0.12.3",
12171218
"uuid": "^11.1.0",
12181219
"vscode-debugadapter": "^1.51.0",
12191220
"vscode-debugprotocol": "^1.51.0",

workspaces/ballerina/ballerina-extension/src/rpc-managers/common/rpc-handler.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,29 @@
2020
import {
2121
BallerinaDiagnosticsRequest,
2222
CommandsRequest,
23-
FileOrDirRequest,
24-
GoToSourceRequest,
25-
OpenExternalUrlRequest,
26-
RunExternalCommandRequest,
27-
ShowErrorMessageRequest,
28-
WorkspaceFileRequest,
23+
downloadSelectedSampleFromGithub,
2924
executeCommand,
3025
experimentalEnabled,
26+
FileOrDirRequest,
3127
getBallerinaDiagnostics,
3228
getCurrentProjectTomlValues,
3329
getTypeCompletions,
3430
getWorkspaceFiles,
3531
getWorkspaceRoot,
3632
getWorkspaceType,
3733
goToSource,
34+
GoToSourceRequest,
3835
isNPSupported,
3936
openExternalUrl,
37+
OpenExternalUrlRequest,
4038
runBackgroundTerminalCommand,
39+
RunExternalCommandRequest,
40+
SampleDownloadRequest,
4141
selectFileOrDirPath,
4242
selectFileOrFolderPath,
43-
showErrorMessage
43+
showErrorMessage,
44+
ShowErrorMessageRequest,
45+
WorkspaceFileRequest
4446
} from "@wso2/ballerina-core";
4547
import { Messenger } from "vscode-messenger";
4648
import { CommonRpcManager } from "./rpc-manager";
@@ -62,4 +64,5 @@ export function registerCommonRpcHandlers(messenger: Messenger) {
6264
messenger.onNotification(showErrorMessage, (args: ShowErrorMessageRequest) => rpcManger.showErrorMessage(args));
6365
messenger.onRequest(getCurrentProjectTomlValues, () => rpcManger.getCurrentProjectTomlValues());
6466
messenger.onRequest(getWorkspaceType, () => rpcManger.getWorkspaceType());
67+
messenger.onRequest(downloadSelectedSampleFromGithub, (args: SampleDownloadRequest) => rpcManger.downloadSelectedSampleFromGithub(args));
6568
}

workspaces/ballerina/ballerina-extension/src/rpc-managers/common/rpc-manager.ts

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,24 @@ import {
3131
FileOrDirResponse,
3232
GoToSourceRequest,
3333
OpenExternalUrlRequest,
34+
PackageTomlValues,
3435
RunExternalCommandRequest,
3536
RunExternalCommandResponse,
37+
SampleDownloadRequest,
3638
ShowErrorMessageRequest,
3739
SyntaxTree,
38-
PackageTomlValues,
3940
TypeResponse,
4041
WorkspaceFileRequest,
4142
WorkspaceRootResponse,
4243
WorkspacesFileResponse,
43-
WorkspaceTypeResponse,
44+
WorkspaceTypeResponse
4445
} from "@wso2/ballerina-core";
4546
import child_process from 'child_process';
46-
import { Uri, commands, env, window, workspace, MarkdownString } from "vscode";
47+
import path from "path";
48+
import os from "os";
49+
import fs from "fs";
50+
import * as unzipper from 'unzipper';
51+
import { commands, env, MarkdownString, ProgressLocation, Uri, window, workspace } from "vscode";
4752
import { URI } from "vscode-uri";
4853
import { extension } from "../../BalExtensionContext";
4954
import { StateMachine } from "../../stateMachine";
@@ -60,9 +65,11 @@ import {
6065
askFilePath,
6166
askProjectPath,
6267
BALLERINA_INTEGRATOR_ISSUES_URL,
63-
getUpdatedSource
68+
getUpdatedSource,
69+
handleDownloadFile,
70+
selectSampleDownloadPath
6471
} from "./utils";
65-
import path from "path";
72+
import { VisualizerWebview } from "../../views/visualizer/webview";
6673

6774
export class CommonRpcManager implements CommonRPCAPI {
6875
async getTypeCompletions(): Promise<TypeResponse> {
@@ -304,4 +311,149 @@ export class CommonRpcManager implements CommonRPCAPI {
304311

305312
return { type: "UNKNOWN" };
306313
}
314+
315+
316+
async downloadSelectedSampleFromGithub(params: SampleDownloadRequest): Promise<boolean> {
317+
const repoUrl = 'https://raw.githubusercontent.com/wso2/integration-samples/refs/heads/main/ballerina-integrator/samples/';
318+
const rawFileLink = repoUrl + params.zipFileName + '.zip';
319+
const defaultDownloadsPath = path.join(os.homedir(), 'Downloads'); // Construct the default downloads path
320+
const pathFromDialog = await selectSampleDownloadPath();
321+
if (pathFromDialog === "") {
322+
return false;
323+
}
324+
const selectedPath = pathFromDialog === "" ? defaultDownloadsPath : pathFromDialog;
325+
const filePath = path.join(selectedPath, params.zipFileName + '.zip');
326+
let isSuccess = false;
327+
328+
if (fs.existsSync(filePath)) {
329+
// already downloaded
330+
isSuccess = true;
331+
} else {
332+
await window.withProgress({
333+
location: ProgressLocation.Notification,
334+
title: 'Downloading file',
335+
cancellable: true
336+
}, async (progress, cancellationToken) => {
337+
338+
let cancelled: boolean = false;
339+
cancellationToken.onCancellationRequested(async () => {
340+
cancelled = true;
341+
// Clean up partial download
342+
if (fs.existsSync(filePath)) {
343+
fs.unlinkSync(filePath);
344+
}
345+
});
346+
347+
try {
348+
await handleDownloadFile(rawFileLink, filePath, progress);
349+
isSuccess = true;
350+
return;
351+
} catch (error) {
352+
window.showErrorMessage(`Error while downloading the file: ${error}`);
353+
}
354+
});
355+
}
356+
357+
if (isSuccess) {
358+
const successMsg = `The Integration sample file has been downloaded successfully to the following directory: ${filePath}.`;
359+
const zipReadStream = fs.createReadStream(filePath);
360+
if (fs.existsSync(path.join(selectedPath, params.zipFileName))) {
361+
// already extracted
362+
let uri = Uri.file(path.join(selectedPath, params.zipFileName));
363+
commands.executeCommand("vscode.openFolder", uri, true);
364+
return true;
365+
}
366+
367+
let extractionError: Error | null = null;
368+
const parseStream = unzipper.Parse();
369+
370+
// Handle errors on the read stream
371+
zipReadStream.on("error", (error) => {
372+
extractionError = error;
373+
window.showErrorMessage(`Failed to read zip file: ${error.message}`);
374+
});
375+
376+
// Handle errors on the parse stream
377+
parseStream.on("error", (error) => {
378+
extractionError = error;
379+
window.showErrorMessage(`Failed to parse zip file. The file may be corrupted: ${error.message}`);
380+
});
381+
382+
parseStream.on("entry", function (entry) {
383+
// Skip processing if we've already encountered an error
384+
if (extractionError) {
385+
entry.autodrain();
386+
return;
387+
}
388+
389+
var isDir = entry.type === "Directory";
390+
var fullpath = path.join(selectedPath, entry.path);
391+
var directory = isDir ? fullpath : path.dirname(fullpath);
392+
393+
try {
394+
if (!fs.existsSync(directory)) {
395+
fs.mkdirSync(directory, { recursive: true });
396+
}
397+
} catch (error) {
398+
extractionError = error as Error;
399+
window.showErrorMessage(`Failed to create directory "${directory}": ${error instanceof Error ? error.message : String(error)}`);
400+
entry.autodrain();
401+
return;
402+
}
403+
404+
if (!isDir) {
405+
const writeStream = fs.createWriteStream(fullpath);
406+
407+
// Handle write stream errors
408+
writeStream.on("error", (error) => {
409+
extractionError = error;
410+
window.showErrorMessage(`Failed to write file "${fullpath}": ${error.message}. This may be due to insufficient disk space or permission issues.`);
411+
entry.autodrain();
412+
});
413+
414+
// Handle entry stream errors
415+
entry.on("error", (error) => {
416+
extractionError = error;
417+
window.showErrorMessage(`Failed to extract entry "${entry.path}": ${error.message}`);
418+
writeStream.destroy();
419+
});
420+
421+
entry.pipe(writeStream);
422+
}
423+
});
424+
425+
parseStream.on("close", () => {
426+
if (extractionError) {
427+
console.error("Extraction failed:", extractionError);
428+
window.showErrorMessage(`Sample extraction failed: ${extractionError.message}`);
429+
return;
430+
}
431+
432+
console.log("Extraction complete!");
433+
window.showInformationMessage('Where would you like to open the project?',
434+
{ modal: true },
435+
'Current Window',
436+
'New Window'
437+
).then(selection => {
438+
if (selection === "Current Window") {
439+
// Dispose the current webview
440+
VisualizerWebview.currentPanel?.dispose();
441+
const folderUri = Uri.file(path.join(selectedPath, params.zipFileName));
442+
const workspaceFolders = workspace.workspaceFolders || [];
443+
if (!workspaceFolders.some(folder => folder.uri.fsPath === folderUri.fsPath)) {
444+
workspace.updateWorkspaceFolders(workspaceFolders.length, 0, { uri: folderUri });
445+
}
446+
} else if (selection === "New Window") {
447+
commands.executeCommand('vscode.openFolder', Uri.file(path.join(selectedPath, params.zipFileName)));
448+
}
449+
});
450+
});
451+
452+
zipReadStream.pipe(parseStream);
453+
window.showInformationMessage(
454+
successMsg,
455+
);
456+
}
457+
return isSuccess;
458+
}
307459
}

workspaces/ballerina/ballerina-extension/src/rpc-managers/common/utils.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,18 @@
1818

1919
import * as os from 'os';
2020
import { NodePosition } from "@wso2/syntax-tree";
21-
import { Position, Range, Uri, window, workspace, WorkspaceEdit } from "vscode";
21+
import { Position, Progress, Range, Uri, window, workspace, WorkspaceEdit } from "vscode";
2222
import { TextEdit } from "@wso2/ballerina-core";
23+
import axios from 'axios';
24+
import fs from 'fs';
2325

2426
export const BALLERINA_INTEGRATOR_ISSUES_URL = "https://github.com/wso2/product-ballerina-integrator/issues";
2527

28+
interface ProgressMessage {
29+
message: string;
30+
increment?: number;
31+
}
32+
2633
export function getUpdatedSource(
2734
statement: string,
2835
currentFileContent: string,
@@ -111,3 +118,75 @@ export async function applyBallerinaTomlEdit(tomlPath: Uri, textEdit: TextEdit)
111118
}
112119
});
113120
}
121+
122+
export async function selectSampleDownloadPath(): Promise<string> {
123+
const folderPath = await window.showOpenDialog({ title: 'Sample download directory', canSelectFolders: true, canSelectFiles: false, openLabel: 'Select Folder' });
124+
if (folderPath && folderPath.length > 0) {
125+
const newlySelectedFolder = folderPath[0].fsPath;
126+
return newlySelectedFolder;
127+
}
128+
return "";
129+
}
130+
131+
async function downloadFile(url: string, filePath: string, progressCallback?: (downloadProgress: any) => void) {
132+
const writer = fs.createWriteStream(filePath);
133+
let totalBytes = 0;
134+
try {
135+
const response = await axios.get(url, {
136+
responseType: 'stream',
137+
headers: {
138+
"User-Agent": "Mozilla/5.0"
139+
},
140+
onDownloadProgress: (progressEvent) => {
141+
totalBytes = progressEvent.total ?? 0;
142+
if (totalBytes === 0) {
143+
// Cannot calculate progress without total size
144+
return;
145+
}
146+
const formatSize = (sizeInBytes: number) => {
147+
const sizeInKB = sizeInBytes / 1024;
148+
if (sizeInKB < 1024) {
149+
return `${Math.floor(sizeInKB)} KB`;
150+
} else {
151+
return `${Math.floor(sizeInKB / 1024)} MB`;
152+
}
153+
};
154+
const progress = {
155+
percentage: Math.round((progressEvent.loaded * 100) / totalBytes),
156+
downloadedAmount: formatSize(progressEvent.loaded),
157+
downloadSize: formatSize(totalBytes)
158+
};
159+
if (progressCallback) {
160+
progressCallback(progress);
161+
}
162+
}
163+
});
164+
response.data.pipe(writer);
165+
await new Promise<void>((resolve, reject) => {
166+
writer.on('finish', () => {
167+
writer.close();
168+
resolve();
169+
});
170+
171+
writer.on('error', (error) => {
172+
reject(error);
173+
});
174+
});
175+
} catch (error) {
176+
window.showErrorMessage(`Error while downloading the file: ${error}`);
177+
throw error;
178+
}
179+
}
180+
181+
export async function handleDownloadFile(rawFileLink: string, defaultDownloadsPath: string, progress: Progress<ProgressMessage>) {
182+
const handleProgress = (progressPercentage) => {
183+
progress.report({ message: "Downloading file...", increment: progressPercentage });
184+
};
185+
try {
186+
await downloadFile(rawFileLink, defaultDownloadsPath, handleProgress);
187+
} catch (error) {
188+
window.showErrorMessage(`Failed to download file: ${error}`);
189+
}
190+
progress.report({ message: "Download finished" });
191+
}
192+

0 commit comments

Comments
 (0)