Skip to content

Commit a6fcf7f

Browse files
authored
Add revision draft support (multiple revisions mode) (#413)
1 parent 8c4c32a commit a6fcf7f

File tree

12 files changed

+319
-41
lines changed

12 files changed

+319
-41
lines changed

package.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@
111111
"title": "%containerApps.editTargetPort%",
112112
"category": "Azure Container Apps"
113113
},
114+
{
115+
"command": "containerApps.createRevisionDraft",
116+
"title": "%containerApps.createRevisionDraft%",
117+
"category": "Azure Container Apps",
118+
"icon": "$(add)"
119+
},
120+
{
121+
"command": "containerApps.editRevisionDraft",
122+
"title": "%containerApps.editRevisionDraft%",
123+
"category": "Azure Container Apps"
124+
},
114125
{
115126
"command": "containerApps.discardRevisionDraft",
116127
"title": "%containerApps.discardRevisionDraft%",
@@ -260,6 +271,16 @@
260271
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i",
261272
"group": "7@2"
262273
},
274+
{
275+
"command": "containerApps.createRevisionDraft",
276+
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraft:false(.*)revisionsItem/i",
277+
"group": "inline@1"
278+
},
279+
{
280+
"command": "containerApps.createRevisionDraft",
281+
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraft:false(.*)revisionsItem/i",
282+
"group": "1@1"
283+
},
263284
{
264285
"command": "containerApps.chooseRevisionMode",
265286
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionsItem/i",
@@ -280,6 +301,21 @@
280301
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionItem(.*)revisionState:active/i",
281302
"group": "2@4"
282303
},
304+
{
305+
"command": "containerApps.discardRevisionDraft",
306+
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i",
307+
"group": "inline@2"
308+
},
309+
{
310+
"command": "containerApps.discardRevisionDraft",
311+
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i",
312+
"group": "1@2"
313+
},
314+
{
315+
"command": "containerApps.editRevisionDraft",
316+
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /revisionDraftItem/i",
317+
"group": "2@1"
318+
},
283319
{
284320
"command": "containerApps.editScalingRange",
285321
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /scaleItem/i",

package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"containerApps.toggleVisibility": "Switch Ingress Visibility...",
1616
"containerApps.editTargetPort": "Edit Target Port...",
1717
"containerApps.chooseRevisionMode": "Choose Revision Mode...",
18+
"containerApps.createRevisionDraft": "Create Draft...",
19+
"containerApps.editRevisionDraft": "Edit Draft (Advanced)...",
1820
"containerApps.discardRevisionDraft": "Discard Changes...",
1921
"containerApps.activateRevision": "Activate Revision",
2022
"containerApps.deactivateRevision": "Deactivate Revision",

src/commands/registerCommands.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import { activateRevision } from './revision/activateRevision';
2626
import { chooseRevisionMode } from './revision/chooseRevisionMode';
2727
import { deactivateRevision } from './revision/deactivateRevision';
2828
import { restartRevision } from './revision/restartRevision';
29+
import { createRevisionDraft } from './revisionDraft/createRevisionDraft';
2930
import { discardRevisionDraft } from './revisionDraft/discardRevisionDraft';
31+
import { editRevisionDraft } from './revisionDraft/editRevisionDraft';
3032
import { addScaleRule } from './scaling/addScaleRule/addScaleRule';
3133
import { editScalingRange } from './scaling/editScalingRange';
3234

@@ -62,6 +64,8 @@ export function registerCommands(): void {
6264
registerCommandWithTreeNodeUnwrapping('containerApps.restartRevision', restartRevision);
6365

6466
// revision draft
67+
registerCommandWithTreeNodeUnwrapping('containerApps.createRevisionDraft', createRevisionDraft);
68+
registerCommandWithTreeNodeUnwrapping('containerApps.editRevisionDraft', editRevisionDraft);
6569
registerCommandWithTreeNodeUnwrapping('containerApps.discardRevisionDraft', discardRevisionDraft);
6670

6771
// scaling
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { ContainerAppsAPIClient, KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers";
7+
import { uiUtils } from "@microsoft/vscode-azext-azureutils";
8+
import { IActionContext, IAzureQuickPickItem, createSubscriptionContext, nonNullProp, nonNullValueAndProp } from "@microsoft/vscode-azext-utils";
9+
import * as dayjs from "dayjs";
10+
// eslint-disable-next-line import/no-internal-modules
11+
import * as relativeTime from 'dayjs/plugin/relativeTime';
12+
import { ext } from "../../extensionVariables";
13+
import type { RevisionItem } from "../../tree/revisionManagement/RevisionItem";
14+
import { RevisionsItem } from "../../tree/revisionManagement/RevisionsItem";
15+
import { createContainerAppsAPIClient } from "../../utils/azureClients";
16+
import { delay } from "../../utils/delay";
17+
import { localize } from "../../utils/localize";
18+
import { pickContainerApp } from "../../utils/pickContainerApp";
19+
import { pickRevisionItem } from "../../utils/pickRevisionItem";
20+
import type { IContainerAppContext } from "../IContainerAppContext";
21+
22+
dayjs.extend(relativeTime);
23+
24+
export async function createRevisionDraft(context: IActionContext, node?: RevisionsItem): Promise<void> {
25+
const containerAppsItem = node ?? await pickContainerApp(context);
26+
27+
if (containerAppsItem.containerApp.revisionsMode !== KnownActiveRevisionsMode.Multiple) {
28+
throw new Error(localize('revisionsModeError', 'You must be in multiple revisions mode to run this command.'));
29+
} else if (ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppsItem)) {
30+
throw new Error(localize('revisionDraftExists', 'A revision draft already exists for container app "{0}".', containerAppsItem.containerApp.name));
31+
}
32+
33+
const { subscription, containerApp } = containerAppsItem;
34+
const containerAppContext: IContainerAppContext = {
35+
...context,
36+
...createSubscriptionContext(subscription),
37+
containerApp,
38+
subscription
39+
};
40+
41+
/**
42+
* Overwrite the typical 'pickRevisionItem' behavior with custom behavior.
43+
* Leverage the `selectRevisionName` option to obtain the RevisionItem without re-prompting the user
44+
*/
45+
const revisionName: string | undefined = await promptForRevisionName(containerAppContext);
46+
const revisionItem: RevisionItem = await pickRevisionItem(context, containerAppsItem, {
47+
selectByRevisionName: revisionName
48+
});
49+
50+
await ext.state.showCreatingChild(
51+
`${revisionItem.containerApp.id}/${RevisionsItem.idSuffix}`,
52+
localize('creatingDraft', 'Creating draft...'),
53+
async () => {
54+
// Add a short delay to display the creating message
55+
await delay(5);
56+
ext.revisionDraftFileSystem.createRevisionDraft(revisionItem);
57+
}
58+
);
59+
}
60+
61+
async function promptForRevisionName(context: IContainerAppContext): Promise<string | undefined> {
62+
const revisionPicks: IAzureQuickPickItem<Revision | undefined>[] = await getRevisionNamePicks(context);
63+
if (revisionPicks.length === 1) {
64+
return revisionPicks[0].data?.name;
65+
}
66+
67+
return (await context.ui.showQuickPick(revisionPicks, {
68+
placeHolder: localize('selectBaseRevision', 'Select a base revision'),
69+
suppressPersistence: true
70+
})).data?.name;
71+
}
72+
73+
async function getRevisionNamePicks(context: IContainerAppContext): Promise<IAzureQuickPickItem<Revision | undefined>[]> {
74+
const rgName: string = nonNullValueAndProp(context.containerApp, 'resourceGroup');
75+
const caName: string = nonNullValueAndProp(context.containerApp, 'name');
76+
77+
const client: ContainerAppsAPIClient = await createContainerAppsAPIClient(context);
78+
79+
const revisionsIterator = client.containerAppsRevisions.listRevisions(rgName, caName);
80+
const revisions: Revision[] = await uiUtils.listAllIterator(revisionsIterator);
81+
82+
if (context.containerApp?.revisionsMode === KnownActiveRevisionsMode.Single) {
83+
return [
84+
{
85+
label: 'Latest',
86+
data: revisions.find((revision: Revision) => revision.name === context.containerApp?.latestRevisionName)
87+
}
88+
];
89+
}
90+
91+
return revisions
92+
.map((revision: Revision) => {
93+
const revisionName = nonNullProp(revision, 'name');
94+
const day = revision.createdTime?.toLocaleDateString(undefined, {
95+
day: '2-digit',
96+
month: '2-digit',
97+
year: '2-digit'
98+
});
99+
const time = revision.createdTime?.toLocaleTimeString(undefined, {
100+
hour: '2-digit',
101+
minute: '2-digit',
102+
second: '2-digit'
103+
});
104+
const timeAgo = dayjs(revision.createdTime).fromNow();
105+
106+
return {
107+
label: revisionName === context.containerApp?.latestRevisionName ? `${revisionName}` : revisionName,
108+
description: (day && time && timeAgo) ?
109+
`${day} ${time} (${revisionName === context.containerApp?.latestRevisionName ? 'Latest' : timeAgo})` :
110+
'',
111+
data: revision,
112+
};
113+
})
114+
.sort((a: IAzureQuickPickItem<Revision>, b: IAzureQuickPickItem<Revision>) => {
115+
const aCreatedTime = nonNullValueAndProp(a.data, 'createdTime');
116+
const bCreatedTime = nonNullValueAndProp(b.data, 'createdTime');
117+
118+
if (aCreatedTime > bCreatedTime) {
119+
return -1;
120+
} else if (aCreatedTime < bCreatedTime) {
121+
return 1;
122+
} else {
123+
return 0;
124+
}
125+
});
126+
}

src/commands/revisionDraft/discardRevisionDraft.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { KnownActiveRevisionsMode } from "@azure/arm-appcontainers";
7-
import { IActionContext } from "@microsoft/vscode-azext-utils";
7+
import type { IActionContext } from "@microsoft/vscode-azext-utils";
88
import { ext } from "../../extensionVariables";
9-
import { ContainerAppItem } from "../../tree/ContainerAppItem";
9+
import type { ContainerAppItem } from "../../tree/ContainerAppItem";
1010
import { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem";
11+
import { delay } from "../../utils/delay";
1112
import { localize } from "../../utils/localize";
1213
import { pickContainerApp } from "../../utils/pickContainerApp";
1314

@@ -20,15 +21,14 @@ export async function discardRevisionDraft(context: IActionContext, node?: Conta
2021
if (containerAppsItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) {
2122
ext.revisionDraftFileSystem.discardRevisionDraft(containerAppsItem);
2223
} else {
23-
// Todo: Add this implementation back in with multiple revisions draft PR
24-
// await ext.state.showDeleting(
25-
// `${containerAppsItem.containerApp.id}/${RevisionDraftItem.idSuffix}`,
26-
// async () => {
27-
// // Add a short delay to display the deleting message
28-
// await delay(5);
29-
// ext.revisionDraftFileSystem.discardRevisionDraft(containerAppsItem);
30-
// }
31-
// );
24+
await ext.state.showDeleting(
25+
`${containerAppsItem.containerApp.id}/${RevisionDraftItem.idSuffix}`,
26+
async () => {
27+
// Add a short delay to display the deleting message
28+
await delay(5);
29+
ext.revisionDraftFileSystem.discardRevisionDraft(containerAppsItem);
30+
}
31+
);
3232
}
3333

3434
ext.state.notifyChildrenChanged(containerAppsItem.containerApp.id);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { KnownActiveRevisionsMode } from "@azure/arm-appcontainers";
7+
import type { IActionContext } from "@microsoft/vscode-azext-utils";
8+
import { ext } from "../../extensionVariables";
9+
import type { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem";
10+
import { localize } from "../../utils/localize";
11+
import { pickContainerApp } from "../../utils/pickContainerApp";
12+
13+
export async function editRevisionDraft(context: IActionContext, node?: RevisionDraftItem): Promise<void> {
14+
const containerAppsItem = node ?? await pickContainerApp(context);
15+
16+
if (containerAppsItem.containerApp.revisionsMode !== KnownActiveRevisionsMode.Multiple) {
17+
throw new Error(localize('revisionsModeError', 'You must be in multiple revisions mode to run this command.'));
18+
} else if (!ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppsItem)) {
19+
// Todo: Prompt the user to create a draft if one doesn't exist
20+
throw new Error(localize('noRevisionDraftExists', 'No revision draft exists for container app "{0}".', containerAppsItem.containerApp.name));
21+
}
22+
23+
await ext.revisionDraftFileSystem.editRevisionDraft(containerAppsItem);
24+
}

src/constants.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,3 @@ export const DOCKERFILE_GLOB_PATTERN = '**/{*.[dD][oO][cC][kK][eE][rR][fF][iI][l
7474

7575
export const revisionModeSingleContextValue: string = 'revisionMode:single';
7676
export const revisionModeMultipleContextValue: string = 'revisionMode:multiple';
77-
78-
// export const revisionDraftTrueContextValue: string = 'revisionDraft:true';
79-
// export const revisionDraftFalseContextValue: string = 'revisionDraft:false';

src/tree/revisionManagement/RevisionItem.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export class RevisionItem implements RevisionsItemModel {
3535

3636
private get contextValue(): string {
3737
const values: string[] = [RevisionItem.contextValue];
38+
39+
// Enable more granular tree item filtering by revision name
40+
values.push(nonNullProp(this.revision, 'name'));
41+
3842
values.push(this.revision.active ? revisionStateActiveContextValue : revisionStateInactiveContextValue);
3943
values.push(this.revisionsMode === KnownActiveRevisionsMode.Single ? revisionModeSingleContextValue : revisionModeMultipleContextValue);
4044
return createContextValue(values);

src/tree/revisionManagement/RevisionsItem.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,59 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import type { Revision } from "@azure/arm-appcontainers";
67
import { uiUtils } from "@microsoft/vscode-azext-azureutils";
78
import { TreeElementBase, callWithTelemetryAndErrorHandling, createContextValue, createSubscriptionContext } from "@microsoft/vscode-azext-utils";
89
import type { AzureSubscription } from "@microsoft/vscode-azureresources-api";
910
import { TreeItem, TreeItemCollapsibleState } from "vscode";
11+
import { ext } from "../../extensionVariables";
1012
import { createContainerAppsAPIClient } from "../../utils/azureClients";
1113
import { localize } from "../../utils/localize";
1214
import { treeUtils } from "../../utils/treeUtils";
1315
import type { ContainerAppModel } from "../ContainerAppItem";
1416
import type { ContainerAppsItem } from "../ContainerAppsBranchDataProvider";
17+
import { RevisionDraftItem } from "./RevisionDraftItem";
1518
import { RevisionItem } from "./RevisionItem";
1619

20+
const revisionDraftTrueContextValue: string = 'revisionDraft:true';
21+
const revisionDraftFalseContextValue: string = 'revisionDraft:false';
22+
1723
export class RevisionsItem implements ContainerAppsItem {
24+
static readonly idSuffix: string = 'revisions';
1825
static readonly contextValue: string = 'revisionsItem';
1926
static readonly contextValueRegExp: RegExp = new RegExp(RevisionsItem.contextValue);
2027

2128
id: string;
2229

2330
constructor(public readonly subscription: AzureSubscription, public readonly containerApp: ContainerAppModel) {
24-
this.id = `${containerApp.id}/Revisions`;
31+
this.id = `${containerApp.id}/${RevisionsItem.idSuffix}`;
2532
}
2633

2734
private get contextValue(): string {
2835
const values: string[] = [RevisionsItem.contextValue];
29-
// values.push(ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(this) ? 'revisionDraft:true' : 'revisionDraft:false');
36+
values.push(ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(this) ? revisionDraftTrueContextValue : revisionDraftFalseContextValue);
3037
return createContextValue(values);
3138
}
3239

3340
async getChildren(): Promise<TreeElementBase[]> {
34-
const result = await callWithTelemetryAndErrorHandling('getChildren', async (context) => {
41+
let revisionDraftBase: Revision | undefined;
42+
const revisionDraftBaseName: string | undefined = ext.revisionDraftFileSystem.getRevisionDraftFile(this)?.baseRevisionName;
43+
44+
const result = (await callWithTelemetryAndErrorHandling('getChildren', async (context) => {
3545
const client = await createContainerAppsAPIClient([context, createSubscriptionContext(this.subscription)]);
3646
const revisions = await uiUtils.listAllIterator(client.containerAppsRevisions.listRevisions(this.containerApp.resourceGroup, this.containerApp.name));
37-
return revisions.map(revision => new RevisionItem(this.subscription, this.containerApp, revision));
38-
});
47+
return revisions.map(revision => {
48+
if (revision.name === revisionDraftBaseName) {
49+
revisionDraftBase = revision;
50+
}
51+
return new RevisionItem(this.subscription, this.containerApp, revision)
52+
});
53+
}))?.reverse() ?? [];
3954

40-
return result?.reverse() ?? [];
55+
return revisionDraftBase ? [
56+
new RevisionDraftItem(this.subscription, this.containerApp, revisionDraftBase),
57+
...result.filter(item => item.revision.name !== revisionDraftBaseName)
58+
] : result;
4159
}
4260

4361
getTreeItem(): TreeItem {

src/tree/scaling/ScaleItem.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { localize } from "../../utils/localize";
1313
import { treeUtils } from "../../utils/treeUtils";
1414
import type { ContainerAppModel } from "../ContainerAppItem";
1515
import type { TreeElementBase } from "../ContainerAppsBranchDataProvider";
16+
import { RevisionDraftItem } from "../revisionManagement/RevisionDraftItem";
1617
import type { RevisionsItemModel } from "../revisionManagement/RevisionItem";
1718
import { createScaleRuleGroupItem } from "./ScaleRuleGroupItem";
1819

@@ -86,9 +87,7 @@ export class ScaleItem implements RevisionsItemModel {
8687
return !!this.containerApp.template?.scale && !deepEqual(this.containerApp.template.scale, scaleDraftTemplate);
8788
} else {
8889
// We only care about showing changes to descendants of the revision draft item when in multiple revisions mode
89-
// return !!this.revision.template?.scale && RevisionDraftItem.hasDescendant(this) && !deepEqual(this.revision.template.scale, scaleDraftTemplate);
90-
91-
return false; // Placeholder
90+
return !!this.revision.template?.scale && RevisionDraftItem.hasDescendant(this) && !deepEqual(this.revision.template.scale, scaleDraftTemplate);
9291
}
9392
}
9493
}

0 commit comments

Comments
 (0)