Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project management updates #16

Merged
merged 7 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
291 changes: 241 additions & 50 deletions src/commands/configureProjectCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,73 +5,264 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

import { QuickPickItem, QuickPickOptions, commands, window } from 'vscode';
import { Uri, WebviewPanel, commands, l10n, window } from 'vscode';
import * as process from 'process';
import { CommonUtils } from '@salesforce/lwc-dev-mobile-core/lib/common/CommonUtils';
import { InstructionsWebviewProvider } from '../webviews';

export class ConfigureProjectCommand {
static async configureProject(
fromWizard: boolean = false
): Promise<string> {
return new Promise(async (resolve, reject) => {
const header: QuickPickOptions = {
placeHolder: 'Create a new project, or open an existing project'
};
const items: QuickPickItem[] = [
{
label: 'Create New Project...',
description:
'Creates a new local project configured with the Offline Starter Kit'
},
{
label: 'Open Existing Project...',
description:
'Opens an existing local project configured with the Offline Starter Kit'
}
];
const selected = await window.showQuickPick(items, header);
if (!selected) {
return resolve('');
}
export type ProjectManagementChoiceAction = (panel?: WebviewPanel) => void;

export interface ProjectConfigurationProcessor {
getProjectManagementChoice(
createChoice: ProjectManagementChoiceAction,
openChoice: ProjectManagementChoiceAction
): void;
getProjectFolderPath(): Promise<Uri[] | undefined>;
preActionUserAcknowledgment(): Promise<void>;
}

if (selected.label === 'Create New Project...') {
const folderUri = await window.showOpenDialog({
openLabel: 'Select project folder',
class DefaultProjectConfigurationProcessor
implements ProjectConfigurationProcessor
{
extensionUri: Uri;
constructor(extensionUri: Uri) {
this.extensionUri = extensionUri;
}

async preActionUserAcknowledgment(): Promise<void> {
return new Promise((resolve) => {
new InstructionsWebviewProvider(
this.extensionUri
).showInstructionWebview(
l10n.t('Offline Starter Kit: Follow the Prompts'),
'src/instructions/projectBootstrapAcknowledgment.html',
[
{
buttonId: 'okButton',
action: async (panel) => {
panel.dispose();
return resolve();
}
}
]
);
});
}

async getProjectFolderPath(): Promise<Uri[] | undefined> {
return new Promise((resolve) => {
window
.showOpenDialog({
openLabel: l10n.t('Select project folder'),
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false
})
.then((result) => {
return resolve(result);
});
if (!folderUri || folderUri.length === 0) {
return resolve('');
});
}

getProjectManagementChoice(
createChoice: ProjectManagementChoiceAction,
openChoice: ProjectManagementChoiceAction
): void {
new InstructionsWebviewProvider(
this.extensionUri
).showInstructionWebview(
l10n.t('Offline Starter Kit: Create or Open Project'),
'src/instructions/projectBootstrapChoice.html',
[
{
buttonId: 'createNewButton',
action: (panel) => {
createChoice(panel);
}
},
{
buttonId: 'openExistingButton',
action: (panel) => {
openChoice(panel);
}
}
]
);
}
}

let infoMessage =
'Follow the prompts to configure the project.';
if (fromWizard) {
infoMessage +=
' NOTE: after the project is loaded, please be patient while the wizard resumes.';
export class ConfigureProjectCommand {
extensionUri: Uri;
projectConfigurationProcessor: ProjectConfigurationProcessor;

constructor(
extensionUri: Uri,
projectConfigurationProcessor?: ProjectConfigurationProcessor
) {
this.extensionUri = extensionUri;
this.projectConfigurationProcessor =
projectConfigurationProcessor ??
new DefaultProjectConfigurationProcessor(extensionUri);
}

async configureProject(): Promise<string | undefined> {
return new Promise(async (resolve) => {
this.projectConfigurationProcessor.getProjectManagementChoice(
(panel) => {
// It's actually important to run this async, because
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

// createNewProject() will not resolve its Promise
// until a path is selected, allowing the user to
// cancel the open dialog and re-initiate it as many
// times as they want.
this.createNewProject(panel).then((path) => {
return resolve(path);
});
},
(panel) => {
// See above for rationale for running this async.
this.openExistingProject(panel).then((path) => {
return resolve(path);
});
}
await window.showInformationMessage(infoMessage, {
title: 'OK'
);
});
}

async createNewProject(panel?: WebviewPanel): Promise<string> {
return new Promise(async (resolve, reject) => {
const folderUri =
await this.projectConfigurationProcessor.getProjectFolderPath();
if (!folderUri || folderUri.length === 0) {
return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reject() here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We explicitly don't want to reject. This is what allows for the "recoverable" case: the Promise will not be fulfilled one way or another, until a proper value is returned, allowing the user to cancel the folder picker any number of times and still continue the workflow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm adding comments to that effect in the relevant areas. It's a good place to add clarity.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, awesome! That makes sense now. Thanks!

}

if (panel) {
panel.dispose();
}

this.projectConfigurationProcessor
.preActionUserAcknowledgment()
.then(async () => {
try {
const path = await this.executeProjectCreation(
folderUri[0]
);
return resolve(path);
} catch (error) {
return reject(error);
}
});
const githubRepoUri: string =
'https://github.com/salesforce/offline-app-developer-starter-kit.git';
try {
});
}

async openExistingProject(panel?: WebviewPanel): Promise<string> {
return new Promise(async (resolve) => {
const folderUri =
await this.projectConfigurationProcessor.getProjectFolderPath();
if (!folderUri || folderUri.length === 0) {
return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reject()?

}

try {
await this.validateProjectFolder(folderUri[0]);
} catch (error) {
window.showErrorMessage((error as Error).message);
return;
}

if (panel) {
panel.dispose();
}

this.projectConfigurationProcessor
.preActionUserAcknowledgment()
.then(async () => {
await commands.executeCommand(
'git.clone',
githubRepoUri,
folderUri[0].fsPath
'vscode.openFolder',
folderUri[0],
{ forceReuseWindow: true }
);
return resolve(folderUri[0].fsPath);
});
});
}

async executeProjectCreation(folderUri: Uri): Promise<string> {
return new Promise(async (resolve) => {
const githubRepoUri: string =
'https://github.com/salesforce/offline-app-developer-starter-kit.git';
await commands.executeCommand(
'git.clone',
githubRepoUri,
folderUri.fsPath
);
return resolve(folderUri.fsPath);
});
}

async validateProjectFolder(projectFolderUri: Uri): Promise<void> {
return new Promise(async (resolve, reject) => {
const origWorkingDir = process.cwd();
try {
// Can we chdir to the selected folder?
try {
process.chdir(projectFolderUri.fsPath);
} catch (error) {
window.showErrorMessage(`Failed to clone: ${error}`);
return reject(error);
return reject(
new Error(
l10n.t(
"Could not access the project folder at '{0}'.",
projectFolderUri.fsPath
)
)
);
}

// Is git installed?
try {
// TODO: There are a number of complexities to solving
// for this in the general platform case.
// Cf. https://github.com/microsoft/vscode/blob/89ec834df20d597ff96f7d303e7e0f2f055d2a4e/extensions/git/src/git.ts#L145-L165
await CommonUtils.executeCommandAsync('git --version');
} catch (error) {
return reject(new Error(l10n.t('git is not installed.')));
}
} else if (selected.label === 'Open Existing Project...') {
console.log('Open existing project');
return resolve('');
} else {
return resolve('');

// Is this a git repo?
try {
await CommonUtils.executeCommandAsync('git status');
} catch (error) {
return reject(
new Error(
l10n.t(
"Folder '{0}' does not contain a git repository.",
projectFolderUri.fsPath
)
)
);
}

// Is this the Offline Starter Kit repo?
try {
const oskInitialCommit =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we put this in constants somewhere perhaps, or at least in a static readonly?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do!

'99b1fa9377694beb7918580aab445a2e9981f611';
await CommonUtils.executeCommandAsync(
`git merge-base HEAD ${oskInitialCommit}`
);
} catch (error) {
return reject(
new Error(
l10n.t(
"The git repository at '{0}' does not share history with the Offline Starter Kit.",
projectFolderUri.fsPath
)
)
);
}

return resolve();
} finally {
process.chdir(origWorkingDir);
}
});
}
Expand Down
7 changes: 4 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ export function activate(context: vscode.ExtensionContext) {
'src/instructions/salesforcemobileapp.html'
);
} else {
const projectDir =
await ConfigureProjectCommand.configureProject(true);
if (projectDir === '') {
const projectDir = await new ConfigureProjectCommand(
context.extensionUri
).configureProject();
if (!projectDir) {
// No directory selected.
return Promise.resolve();
}
Expand Down
18 changes: 18 additions & 0 deletions src/instructions/projectBootstrapAcknowledgment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline Starter Kit Project: Follow the Prompts</title>
</head>
<body>
<p>Follow the prompts to configure the project.</p>
<p>
<strong>NOTE:</strong>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use tags here? ;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tags?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugggh, I was making a joke about using blink tags, but git removed the <blink/>. Sorry!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha you know I had a feeling that's what you meant! But I was like, "But tags? ¯\(ツ)/¯ " We were on the same page all along.

after the project is loaded, please be patient while the wizard
resumes.
</p>
<button id="okButton">OK</button>
<script src="--- MESSAGING_SCRIPT_SRC ---"></script>
</body>
</html>
19 changes: 19 additions & 0 deletions src/instructions/projectBootstrapChoice.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline Starter Kit: Create or Open Project</title>
</head>
<body>
<h1>Welcome to the Offline Starter Kit Onboarding Wizard</h1>
<p>
In order to get started, please choose whether you would like to
create a new instance of the Offline Starter Kit project, or open an
existing instance of the project.
</p>
<button id="createNewButton">Create New Project</button>
<button id="openExistingButton">Open Existing Project</button>
<script src="--- MESSAGING_SCRIPT_SRC ---"></script>
</body>
</html>
Loading