Skip to content

Commit

Permalink
Tweaks to how command execution results in explorer view updates
Browse files Browse the repository at this point in the history
  • Loading branch information
justinwilaby committed Oct 31, 2024
1 parent adf125c commit 1e4f1b3
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 20 deletions.
64 changes: 64 additions & 0 deletions src/extension/commands/heroku-cli/heroku-addon-command-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { AddOn } from '@heroku-cli/schema';
import { CommandMeta } from '../../manifest';
import { herokuCommand, HerokuOutputChannel } from '../../meta/command';
import { HerokuCommandRunner } from './heroku-command-runner';

@herokuCommand({ outputChannelId: HerokuOutputChannel.CommandOutput })
/**
* Any other commands. This acts as a catch-all for commands
* that do to have a dedicated command runner.
*/
export class HerokuAddOnCommandRunner extends HerokuCommandRunner<unknown> {
public static COMMAND_ID = 'heroku:addOn:runner';

/**
*
* @inheritdoc
*/
protected hydrateArgs(
userInputByArg: Map<string, string | undefined>,
args: CommandMeta['args'],
addOn: AddOn
): PromiseLike<void> | void {
if (args.app?.required && addOn) {
userInputByArg.set('app', addOn.app.name);
}
if (args.addon?.required && addOn) {
userInputByArg.set('addon', addOn.name);
}
if (args.addonName?.required && addOn) {
userInputByArg.set('addonName', addOn.name);
}
}

/**
*
* @inheritdoc
*/
protected hydrateFlags(
userInputByFlag: Map<string, string | undefined>,
flags: CommandMeta['flags'],
addOn: AddOn
): PromiseLike<void> | void {
if (flags.app && addOn) {
userInputByFlag.set('app', addOn.app.name);
}
if (flags.addon?.required && addOn) {
userInputByFlag.set('addon', addOn.name);
}
if (flags.addonName?.required && addOn) {
userInputByFlag.set('addonName', addOn.name);
}
// Special case for destructive actions e.g. ones with a `confirm` prompt
if (flags.confirm) {
Reflect.set(flags.confirm, 'required', true);
Reflect.set(flags.confirm, 'type', 'boolean');
Reflect.set(flags.confirm, 'default', true);
Reflect.set(flags.confirm, 'hidden', false);
Reflect.set(flags.confirm, 'default', addOn?.app?.name);
if (!flags.confirm.description) {
Reflect.set(flags.confirm, 'description', 'this is a destructive action which cannot be undone');
}
}
}
}
11 changes: 11 additions & 0 deletions src/extension/commands/heroku-cli/heroku-apps-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,16 @@ export class HerokuAppsRunner extends HerokuCommandRunner<App> {
if (flags.app?.required && app) {
userInputByFlag.set('app', app.name);
}
// Special case for destructive actions e.g. ones with a `confirm` prompt
if (flags.confirm) {
Reflect.set(flags.confirm, 'required', true);
Reflect.set(flags.confirm, 'type', 'boolean');
Reflect.set(flags.confirm, 'default', true);
Reflect.set(flags.confirm, 'hidden', false);
Reflect.set(flags.confirm, 'default', app?.name);
if (!flags.confirm.description) {
Reflect.set(flags.confirm, 'description', 'this is a destructive action which cannot be undone');
}
}
}
}
22 changes: 15 additions & 7 deletions src/extension/commands/heroku-cli/heroku-command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ export abstract class HerokuCommandRunner<T> extends HerokuCommand<void> {
return 0;
});

keysByType.forEach((flag) => {
const isRequired = Reflect.get(flagsOrArgsManifest[flag], 'required');
(isRequired ? requiredInputs : optionalInputs).push(flag);
keysByType.forEach((key) => {
const isRequired = Reflect.get(flagsOrArgsManifest[key], 'required') ?? key === 'confirm';
(isRequired ? requiredInputs : optionalInputs).push(key);
});
// Prioritize required inputs
// over optional inputs when
Expand All @@ -113,7 +113,7 @@ export abstract class HerokuCommandRunner<T> extends HerokuCommand<void> {
if (userInputMap.has(flagOrArg)) {
continue;
}
// Some inputs do not have a description - I sure hope these aren't required

const {
description,
type,
Expand All @@ -133,13 +133,21 @@ export abstract class HerokuCommandRunner<T> extends HerokuCommand<void> {
// which requires typing a yes/no response,
// we use an information message with "yes", "no"
// or cancel button choices.
const choice = await vscode.window.showInformationMessage(`Should we ${description}?`, 'Yes', 'No', 'Cancel');
const isConfirm = flagOrArg === 'confirm';
const message = isConfirm
? `I sure hope you know what your doing: ${description}.\n`
: `Should we ${description}?`;
const items = isConfirm ? ['Proceed Anyway', 'Cancel'] : ['Yes', 'No', 'Cancel'];
const choice = await (isConfirm ? vscode.window.showWarningMessage : vscode.window.showInformationMessage)(
message,
...items
);
// user cancelled
if (choice === undefined || choice === 'Cancel') {
return true;
}
if (choice === 'Yes') {
userInputMap.set(flagOrArg, undefined);
if (choice === 'Yes' || choice === 'Proceed Anyway') {
userInputMap.set(flagOrArg, defaultValue);
}
} else {
const input = await vscode.window.showInputBox({
Expand Down
27 changes: 22 additions & 5 deletions src/extension/commands/heroku-cli/heroku-unknown-command-runner.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import { CommandMeta } from '../../manifest';
import { herokuCommand, HerokuOutputChannel } from '../../meta/command';
import { HerokuCommandRunner } from './heroku-command-runner';

@herokuCommand({ outputChannelId: HerokuOutputChannel.CommandOutput })
/**
* Any other commands.
* Any other commands. This acts as a catch-all for commands
* that do to have a dedicated command runner.
*/
export class HerokuUnknownCommandRunner extends HerokuCommandRunner<unknown> {
public static COMMAND_ID = 'heroku:unknown:runner';
/**
*
* @inheritdoc
*/
protected hydrateFlags(): PromiseLike<void> | void {
// noop
protected hydrateArgs(
userInputByArg: Map<string, string | undefined>,
args: CommandMeta['args'],
unknownData?: { app?: { id: string; name: string }; id?: string; name?: string }
): PromiseLike<void> | void {
if (args.app?.required && unknownData) {
userInputByArg.set('app', unknownData.name ?? unknownData.app?.name);
}
}

/**
*
* @inheritdoc
*/
protected hydrateArgs(): PromiseLike<void> | void {
// noop
protected hydrateFlags(
userInputByFlag: Map<string, string | undefined>,
flags: CommandMeta['flags'],
unknownData?: { app?: { id: string; name: string }; id?: string; name?: string }
): PromiseLike<void> | void {
if (flags.app?.required && unknownData) {
userInputByFlag.set('app', unknownData.name ?? unknownData.app?.name);
}
}
}
3 changes: 3 additions & 0 deletions src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { HerokuPsRunner } from './commands/heroku-cli/heroku-ps-runner';
import { HerokuPgRunner } from './commands/heroku-cli/heroku-pg-runner';
import { HerokuRedisRunner } from './commands/heroku-cli/heroku-redis-runner';
import { HerokuUnknownCommandRunner } from './commands/heroku-cli/heroku-unknown-command-runner';
import { HerokuAddOnCommandRunner } from './commands/heroku-cli/heroku-addon-command-runner';

/**
* Called when the extension is activated by VSCode
Expand Down Expand Up @@ -59,6 +60,8 @@ function registerCommandsfromManifest(): vscode.Disposable[] {
void vscode.commands.executeCommand(HerokuPgRunner.COMMAND_ID, command, ...args);
} else if (/^(redis)(:?)/.test(command)) {
void vscode.commands.executeCommand(HerokuRedisRunner.COMMAND_ID, command, ...args);
} else if (/^(addons)(:?)/.test(command)) {
void vscode.commands.executeCommand(HerokuAddOnCommandRunner.COMMAND_ID, command, ...args);
} else {
void vscode.commands.executeCommand(HerokuUnknownCommandRunner.COMMAND_ID, command, ...args);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
LogStreamEvents,
type StartingProcessInfo,
type ScaledToInfo,
type StateChangedInfo
type StateChangedInfo,
AttachmentProvisionedInfo,
AttachmentDetachedInfo
} from './log-stream-client';
import {
getAddOnTreeItem,
Expand Down Expand Up @@ -90,6 +92,8 @@ export class HerokuResourceExplorerProvider<T extends ExtendedTreeDataTypes = Ex
this.#logStreamClient.addListener(LogStreamEvents.SCALED_TO, this.onFormationScaledTo);
this.#logStreamClient.addListener(LogStreamEvents.STATE_CHANGED, this.onDynoStateChanged);
this.#logStreamClient.addListener(LogStreamEvents.STARTING_PROCESS, this.onDynoProcessStarting);
this.#logStreamClient.addListener(LogStreamEvents.PROVISIONING_COMPLETED, this.onAttachmentProvisioned);
this.#logStreamClient.addListener(LogStreamEvents.ATTACHMENT_DETACHED, this.onAttachmentDetached);

this.#logStreamClient.addListener(LogStreamEvents.MUTED_CHANGED, this.onStreamMutedChanged);
}
Expand Down Expand Up @@ -227,7 +231,7 @@ export class HerokuResourceExplorerProvider<T extends ExtendedTreeDataTypes = Ex
* @param data The data dispatched when a dyno state is changed.
*/
private onDynoStateChanged = (data: StateChangedInfo): void => {
const { app, dynoName, from, to } = data;
const { app, dynoName, to } = data;
const { dynos } = this.appToResourceMap.get(app)!;
const dyno = dynos.find((d) => d.name === dynoName);
if (!dyno) {
Expand All @@ -244,9 +248,7 @@ export class HerokuResourceExplorerProvider<T extends ExtendedTreeDataTypes = Ex
// logs. Since Dynos are restarted with *most* changes
// to an add-on, we must check for add-ons being
// provisioned/deprovisioned when dynos restart.
if (from === 'up' && to === 'starting') {
void this.queueAddOnSynchronization(app);
}
void this.queueAddOnSynchronization(app);
};

/**
Expand All @@ -259,15 +261,27 @@ export class HerokuResourceExplorerProvider<T extends ExtendedTreeDataTypes = Ex
return;
}
this.syncAddonsPending = true;
await new Promise((resolve) => setTimeout(resolve, 5000)); // wait for log chatter to settle a bit
const { addOns, categories } = this.appToResourceMap.get(app)!;
const addonsMap = new Set(addOns.map((a) => a.id));
const addonsSet = new Set(addOns.map((a) => a.id));

addOns.length = 0;
const addOnsCategory = categories.find((cat) => cat.label === 'ADD-ONS');
const pristineAddons = await this.getAddonsForApp(app, addOnsCategory as T);
const pristineAddonsSet = new Set(pristineAddons.map((a) => a.id));
// Sync properties
addonsSet.forEach((id) => {
if (pristineAddonsSet.has(id)) {
const addon = addOns.find((a) => a.id === id);
const pristineAddon = pristineAddons.find((a) => a.id === id);
if (addon && pristineAddon) {
Object.assign(addon, pristineAddon);
this.fire(addon as T);
}
}
});

const pristineAddonsMap = new Set(pristineAddons.map((a) => a.id));
if (addonsMap.difference(pristineAddonsMap).size || pristineAddonsMap.difference(addonsMap).size) {
if (addonsSet.difference(pristineAddonsSet).size || pristineAddonsSet.difference(addonsSet).size) {
this.fire(addOnsCategory as T);
}
this.syncAddonsPending = false;
Expand Down Expand Up @@ -313,6 +327,34 @@ export class HerokuResourceExplorerProvider<T extends ExtendedTreeDataTypes = Ex
}
};

/**
* Handler for when attachments complete provisioning
*
* @param data The data dispatched when the attachment completes provisioning
*/
private onAttachmentProvisioned = (data: AttachmentProvisionedInfo): void => {
const { app, ref } = data;
const { addOns } = this.appToResourceMap.get(app)!;
if (addOns) {
const addOn = addOns.find((a) => a.id === ref);
if (addOn) {
Reflect.set(addOn, 'state', 'provisioned');
this.fire(addOn as T);
} else {
void this.queueAddOnSynchronization(app);
}
}
};

/**
* Handler for when attachments are detached
*
* @param data The data dispatched when the attachment detaches
*/
private onAttachmentDetached = (data: AttachmentDetachedInfo): void => {
void this.queueAddOnSynchronization(data.app);
};

/**
* Updates the app tree item to display the
* appropriate icon when a log stream is started
Expand Down
2 changes: 2 additions & 0 deletions src/webviews/addons-view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,5 +435,7 @@ export class HerokuAddOnsMarketplace extends FASTElement {
button.innerHTML = isInstalledAddon ? 'Modify&nbsp;plan' : 'install';
button.disabled = false;
button.appearance = 'secondary';
button.addEventListener('click', this.onInstallClick);
button.removeEventListener('click', this.onSubmitOrUpdate);
};
}

0 comments on commit 1e4f1b3

Please sign in to comment.