diff --git a/package-lock.json b/package-lock.json
index 80f3be0bd..703e9ccb4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@microsoft/vscode-azureresources-api": "^2.0.2",
"buffer": "^6.0.3",
"dayjs": "^1.11.3",
+ "deep-eql": "^4.1.3",
"dotenv": "^16.0.0",
"fs-extra": "^8.1.0",
"semver": "^7.5.2",
@@ -32,6 +33,7 @@
"@azure/ms-rest-azure-env": "^2.0.0",
"@microsoft/eslint-config-azuretools": "^0.2.1",
"@microsoft/vscode-azext-dev": "^2.0.1",
+ "@types/deep-eql": "^4.0.0",
"@types/fs-extra": "^8.1.1",
"@types/gulp": "^4.0.6",
"@types/mocha": "^8.2.2",
@@ -1173,6 +1175,12 @@
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==",
"dev": true
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.0.tgz",
+ "integrity": "sha512-UkfTYLA64vGUyR4ez8ybIC01wtilS/ydOqKL1fSHOvnX1sVm1sI5btr7Q+bqpdaPGD+QNhOgBXq1HGjO+99e1A==",
+ "dev": true
+ },
"node_modules/@types/eslint": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz",
@@ -3562,6 +3570,17 @@
"node": ">=8"
}
},
+ "node_modules/deep-eql": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+ "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -10825,6 +10844,14 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
@@ -12753,6 +12780,12 @@
"integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==",
"dev": true
},
+ "@types/deep-eql": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.0.tgz",
+ "integrity": "sha512-UkfTYLA64vGUyR4ez8ybIC01wtilS/ydOqKL1fSHOvnX1sVm1sI5btr7Q+bqpdaPGD+QNhOgBXq1HGjO+99e1A==",
+ "dev": true
+ },
"@types/eslint": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz",
@@ -14599,6 +14632,14 @@
"mimic-response": "^2.0.0"
}
},
+ "deep-eql": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+ "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+ "requires": {
+ "type-detect": "^4.0.0"
+ }
+ },
"deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -20235,6 +20276,11 @@
"prelude-ls": "^1.2.1"
}
},
+ "type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="
+ },
"type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
diff --git a/package.json b/package.json
index bd2423313..42666b972 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,16 @@
"category": "Azure Container Apps",
"icon": "$(add)"
},
+ {
+ "command": "containerApps.deleteManagedEnvironment",
+ "title": "%containerApps.deleteManagedEnvironment%",
+ "category": "Azure Container Apps"
+ },
+ {
+ "command": "containerApps.editContainerApp",
+ "title": "%containerApps.editContainerApp%",
+ "category": "Azure Container Apps"
+ },
{
"command": "containerApps.deployImage",
"title": "%containerApps.deployImage%",
@@ -76,11 +86,6 @@
"title": "%containerApps.deployImageApi%",
"category": "Azure Container Apps"
},
- {
- "command": "containerApps.deleteManagedEnvironment",
- "title": "%containerApps.deleteManagedEnvironment%",
- "category": "Azure Container Apps"
- },
{
"command": "containerApps.deleteContainerApp",
"title": "%containerApps.deleteContainerApp%",
@@ -106,6 +111,12 @@
"title": "%containerApps.editTargetPort%",
"category": "Azure Container Apps"
},
+ {
+ "command": "containerApps.discardRevisionDraft",
+ "title": "%containerApps.discardRevisionDraft%",
+ "category": "Azure Container Apps",
+ "icon": "$(discard)"
+ },
{
"command": "containerApps.chooseRevisionMode",
"title": "%containerApps.chooseRevisionMode%",
@@ -215,24 +226,39 @@
"group": "2@2"
},
{
- "command": "containerApps.deployImage",
- "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i",
- "group": "3@1"
+ "command": "containerApps.discardRevisionDraft",
+ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single(.*)unsavedChanges:true/i",
+ "group": "inline@2"
+ },
+ {
+ "command": "containerApps.discardRevisionDraft",
+ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single(.*)unsavedChanges:true/i",
+ "group": "3@2"
+ },
+ {
+ "command": "containerApps.editContainerApp",
+ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem(.*)revisionMode:single/i",
+ "group": "4@1"
},
{
"command": "containerApps.deleteContainerApp",
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i",
- "group": "3@2"
+ "group": "5@1"
+ },
+ {
+ "command": "containerApps.deployImage",
+ "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i",
+ "group": "6@1"
},
{
"command": "containerApps.startStreamingLogs",
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i",
- "group": "4@1"
+ "group": "7@1"
},
{
"command": "containerApps.stopStreamingLogs",
"when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /containerAppItem/i",
- "group": "4@2"
+ "group": "7@2"
},
{
"command": "containerApps.chooseRevisionMode",
@@ -351,6 +377,7 @@
"@azure/ms-rest-azure-env": "^2.0.0",
"@microsoft/eslint-config-azuretools": "^0.2.1",
"@microsoft/vscode-azext-dev": "^2.0.1",
+ "@types/deep-eql": "^4.0.0",
"@types/fs-extra": "^8.1.1",
"@types/gulp": "^4.0.6",
"@types/mocha": "^8.2.2",
@@ -388,6 +415,7 @@
"@microsoft/vscode-azureresources-api": "^2.0.2",
"buffer": "^6.0.3",
"dayjs": "^1.11.3",
+ "deep-eql": "^4.1.3",
"dotenv": "^16.0.0",
"fs-extra": "^8.1.0",
"semver": "^7.5.2",
diff --git a/package.nls.json b/package.nls.json
index 830b70ee2..075fb9d2a 100644
--- a/package.nls.json
+++ b/package.nls.json
@@ -6,7 +6,8 @@
"containerApps.enableOutputTimestamps": "Prepends each line displayed in the output channel with a timestamp.",
"containerApps.browse": "Browse",
"containerApps.createContainerApp": "Create Container App...",
- "containerApps.deployImage": "Update Container App Image......",
+ "containerApps.editContainerApp": "Edit Container App (Advanced)...",
+ "containerApps.deployImage": "Update Container App Image...",
"containerApps.deployImageApi": "Update Container App Image (API)...",
"containerApps.deleteContainerApp": "Delete Container App...",
"containerApps.disableIngress": "Disable Ingress for Container App",
@@ -14,6 +15,7 @@
"containerApps.toggleVisibility": "Switch Ingress Visibility...",
"containerApps.editTargetPort": "Edit Target Port...",
"containerApps.chooseRevisionMode": "Choose Revision Mode...",
+ "containerApps.discardRevisionDraft": "Discard Changes...",
"containerApps.activateRevision": "Activate Revision",
"containerApps.deactivateRevision": "Deactivate Revision",
"containerApps.restartRevision": "Restart Revision",
diff --git a/resources/revision-draft.svg b/resources/revision-draft.svg
new file mode 100644
index 000000000..c808f0c60
--- /dev/null
+++ b/resources/revision-draft.svg
@@ -0,0 +1,36 @@
+
diff --git a/src/commands/deployImage/imageSource/buildImageInAzure/buildImageInAzure.ts b/src/commands/deployImage/imageSource/buildImageInAzure/buildImageInAzure.ts
index 0a2d9cb43..0c91fc1b9 100644
--- a/src/commands/deployImage/imageSource/buildImageInAzure/buildImageInAzure.ts
+++ b/src/commands/deployImage/imageSource/buildImageInAzure/buildImageInAzure.ts
@@ -6,6 +6,7 @@
import type { Run as AcrRun } from '@azure/arm-containerregistry';
import { KnownRunStatus } from '@azure/arm-containerregistry';
import { nonNullValue } from '@microsoft/vscode-azext-utils';
+import { delay } from '../../../../utils/delay';
import { IBuildImageInAzureContext } from "./IBuildImageInAzureContext";
const WAIT_MS = 5000;
@@ -25,7 +26,3 @@ export async function buildImageInAzure(context: IBuildImageInAzureContext): Pro
return run;
}
-
-async function delay(ms: number): Promise {
- await new Promise((resolve: () => void): NodeJS.Timer => setTimeout(resolve, ms));
-}
diff --git a/src/commands/editContainerApp.ts b/src/commands/editContainerApp.ts
new file mode 100644
index 000000000..bc0a630b4
--- /dev/null
+++ b/src/commands/editContainerApp.ts
@@ -0,0 +1,21 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { KnownActiveRevisionsMode } from "@azure/arm-appcontainers";
+import type { IActionContext } from "@microsoft/vscode-azext-utils";
+import { ext } from "../extensionVariables";
+import type { ContainerAppItem } from "../tree/ContainerAppItem";
+import { localize } from "../utils/localize";
+import { pickContainerApp } from "../utils/pickContainerApp";
+
+export async function editContainerApp(context: IActionContext, node?: ContainerAppItem): Promise {
+ node ??= await pickContainerApp(context);
+
+ if (node.containerApp.revisionsMode !== KnownActiveRevisionsMode.Single) {
+ throw new Error(localize('revisionModeError', 'The issued command can only be executed when the container app is in single revision mode.'));
+ }
+
+ await ext.revisionDraftFileSystem.editRevisionDraft(node);
+}
diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts
index 2551803bc..b69a74125 100644
--- a/src/commands/registerCommands.ts
+++ b/src/commands/registerCommands.ts
@@ -11,6 +11,7 @@ import { deleteContainerApp } from './deleteContainerApp/deleteContainerApp';
import { deleteManagedEnvironment } from './deleteManagedEnvironment/deleteManagedEnvironment';
import { deployImage } from './deployImage/deployImage';
import { deployImageApi } from './deployImage/deployImageApi';
+import { editContainerApp } from './editContainerApp';
import { connectToGitHub } from './gitHub/connectToGitHub/connectToGitHub';
import { disconnectRepo } from './gitHub/disconnectRepo/disconnectRepo';
import { openGitHubRepo } from './gitHub/openGitHubRepo';
@@ -25,6 +26,7 @@ import { activateRevision } from './revision/activateRevision';
import { chooseRevisionMode } from './revision/chooseRevisionMode';
import { deactivateRevision } from './revision/deactivateRevision';
import { restartRevision } from './revision/restartRevision';
+import { discardRevisionDraft } from './revisionDraft/discardRevisionDraft';
import { addScaleRule } from './scaling/addScaleRule/addScaleRule';
import { editScalingRange } from './scaling/editScalingRange';
@@ -35,6 +37,7 @@ export function registerCommands(): void {
// container apps
registerCommandWithTreeNodeUnwrapping('containerApps.createContainerApp', createContainerApp);
+ registerCommandWithTreeNodeUnwrapping('containerApps.editContainerApp', editContainerApp);
registerCommandWithTreeNodeUnwrapping('containerApps.deleteContainerApp', deleteContainerApp);
registerCommandWithTreeNodeUnwrapping('containerApps.deployImage', deployImage);
registerCommandWithTreeNodeUnwrapping('containerApps.deployImageApi', deployImageApi);
@@ -58,6 +61,9 @@ export function registerCommands(): void {
registerCommandWithTreeNodeUnwrapping('containerApps.deactivateRevision', deactivateRevision);
registerCommandWithTreeNodeUnwrapping('containerApps.restartRevision', restartRevision);
+ // revision draft
+ registerCommandWithTreeNodeUnwrapping('containerApps.discardRevisionDraft', discardRevisionDraft);
+
// scaling
registerCommandWithTreeNodeUnwrapping('containerApps.editScalingRange', editScalingRange);
registerCommandWithTreeNodeUnwrapping('containerApps.addScaleRule', addScaleRule);
diff --git a/src/commands/revisionDraft/RevisionDraftFileSystem.ts b/src/commands/revisionDraft/RevisionDraftFileSystem.ts
new file mode 100644
index 000000000..c8e87a27e
--- /dev/null
+++ b/src/commands/revisionDraft/RevisionDraftFileSystem.ts
@@ -0,0 +1,198 @@
+/*---------------------------------------------------------------------------------------------
+* Copyright (c) Microsoft Corporation. All rights reserved.
+* Licensed under the MIT License. See License.txt in the project root for license information.
+*--------------------------------------------------------------------------------------------*/
+
+import type { Template } from "@azure/arm-appcontainers";
+import { nonNullValueAndProp } from "@microsoft/vscode-azext-utils";
+import { Disposable, Event, EventEmitter, FileChangeEvent, FileChangeType, FileStat, FileSystemProvider, FileType, TextDocument, Uri, window, workspace } from "vscode";
+import { URI } from "vscode-uri";
+import { ext } from "../../extensionVariables";
+import { ContainerAppItem } from "../../tree/ContainerAppItem";
+import type { ContainerAppsItem } from "../../tree/ContainerAppsBranchDataProvider";
+import type { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem";
+import type { RevisionItem } from "../../tree/revisionManagement/RevisionItem";
+import { localize } from "../../utils/localize";
+
+const notSupported: string = localize('notSupported', 'This operation is not currently supported.');
+
+export class RevisionDraftFile implements FileStat {
+ type: FileType = FileType.File;
+ size: number;
+ ctime: number;
+ mtime: number;
+
+ contents: Uint8Array;
+
+ constructor(contents: Uint8Array, readonly containerAppId: string, readonly baseRevisionName: string) {
+ this.contents = contents;
+ this.size = contents.byteLength;
+ this.ctime = Date.now();
+ this.mtime = Date.now();
+ }
+}
+
+/**
+ * File system provider that allows for reading/writing/deploying container app revision drafts
+ *
+ * Enforces a policy of one revision draft per container app
+ */
+export class RevisionDraftFileSystem implements FileSystemProvider {
+ static readonly scheme: string = 'containerAppsRevisionDraft';
+
+ private readonly emitter: EventEmitter = new EventEmitter();
+ private readonly bufferedEvents: FileChangeEvent[] = [];
+ private fireSoonHandle?: NodeJS.Timer;
+
+ private draftStore: Map = new Map();
+
+ get onDidChangeFile(): Event {
+ return this.emitter.event;
+ }
+
+ // Create
+ createRevisionDraft(item: ContainerAppItem | RevisionItem | RevisionDraftItem): void {
+ const uri: Uri = this.buildUriFromItem(item);
+ if (this.draftStore.has(uri.path)) {
+ return;
+ }
+
+ let file: RevisionDraftFile | undefined;
+ if (item instanceof ContainerAppItem) {
+ /**
+ * Sometimes there are micro-differences present between the latest revision template and the container app template.
+ * They end up being essentially equivalent, but not so equivalent as to always pass a deep copy test (which is how we detect unsaved changes).
+ *
+ * Since only container app template data is shown in single revision mode, and since revision data is not directly present on
+ * the container app tree item without further changes, it is easier to just use the container app template
+ * as the primary source of truth when in single revision mode.
+ */
+ const revisionContent: Uint8Array = Buffer.from(JSON.stringify(nonNullValueAndProp(item.containerApp, 'template'), undefined, 4));
+ file = new RevisionDraftFile(revisionContent, item.containerApp.id, nonNullValueAndProp(item.containerApp, 'latestRevisionName'));
+ } else {
+ const revisionContent: Uint8Array = Buffer.from(JSON.stringify(nonNullValueAndProp(item.revision, 'template'), undefined, 4));
+ file = new RevisionDraftFile(revisionContent, item.containerApp.id, nonNullValueAndProp(item.revision, 'name'));
+ }
+
+ this.draftStore.set(uri.path, file);
+ this.fireSoon({ type: FileChangeType.Created, uri });
+ }
+
+ // Read
+ parseRevisionDraft(item: T): Template | undefined {
+ const uri: URI = this.buildUriFromItem(item);
+ if (!this.draftStore.has(uri.path)) {
+ return undefined;
+ }
+
+ return JSON.parse(this.readFile(uri).toString()) as Template;
+ }
+
+ readFile(uri: Uri): Uint8Array {
+ const contents = this.draftStore.get(uri.path)?.contents;
+ return contents ? Buffer.from(contents) : Buffer.from('');
+ }
+
+ doesContainerAppsItemHaveRevisionDraft(item: T): boolean {
+ const uri: Uri = this.buildUriFromItem(item);
+ return this.draftStore.has(uri.path);
+ }
+
+ getRevisionDraftFile(item: T): RevisionDraftFile | undefined {
+ const uri: Uri = this.buildUriFromItem(item);
+ return this.draftStore.get(uri.path);
+ }
+
+ stat(uri: Uri): FileStat {
+ const file: RevisionDraftFile | undefined = this.draftStore.get(uri.path);
+
+ if (file) {
+ return {
+ type: file.type,
+ ctime: file.ctime,
+ mtime: file.mtime,
+ size: file.size
+ };
+ } else {
+ return { type: FileType.File, ctime: 0, mtime: 0, size: 0 };
+ }
+ }
+
+ // Update
+ async editRevisionDraft(item: ContainerAppItem | RevisionItem | RevisionDraftItem): Promise {
+ const uri: Uri = this.buildUriFromItem(item);
+ if (!this.draftStore.has(uri.path)) {
+ this.createRevisionDraft(item);
+ }
+
+ const textDoc: TextDocument = await workspace.openTextDocument(uri);
+ await window.showTextDocument(textDoc);
+ }
+
+ writeFile(uri: Uri, contents: Uint8Array): void {
+ const file: RevisionDraftFile | undefined = this.draftStore.get(uri.path);
+ if (!file || file.contents === contents) {
+ return;
+ }
+
+ file.contents = contents;
+ file.size = contents.byteLength;
+ file.mtime = Date.now();
+
+ this.draftStore.set(uri.path, file);
+ this.fireSoon({ type: FileChangeType.Changed, uri });
+
+ // Any new changes to the draft file can cause the states of a container app's children to change (e.g. displaying "Unsaved changes")
+ ext.state.notifyChildrenChanged(file.containerAppId);
+ }
+
+ // Delete
+ discardRevisionDraft(item: T): void {
+ const uri: Uri = this.buildUriFromItem(item);
+ if (!this.draftStore.has(uri.path)) {
+ return;
+ }
+
+ this.delete(uri);
+ }
+
+ delete(uri: Uri): void {
+ this.draftStore.delete(uri.path);
+ this.fireSoon({ type: FileChangeType.Deleted, uri });
+ }
+
+ // Helper
+ private buildUriFromItem(item: T): Uri {
+ return URI.parse(`${RevisionDraftFileSystem.scheme}:/${item.containerApp.name}.json`);
+ }
+
+ // Adapted from: https://github.com/microsoft/vscode-extension-samples/blob/master/fsprovider-sample/src/fileSystemProvider.ts
+ fireSoon(...events: FileChangeEvent[]): void {
+ this.bufferedEvents.push(...events);
+
+ if (this.fireSoonHandle) {
+ clearTimeout(this.fireSoonHandle);
+ }
+
+ this.fireSoonHandle = setTimeout(() => {
+ this.emitter.fire(this.bufferedEvents);
+ this.bufferedEvents.length = 0;
+ }, 5);
+ }
+
+ watch(): Disposable {
+ return new Disposable((): void => { /** Do nothing */ });
+ }
+
+ readDirectory(): [string, FileType][] {
+ throw new Error(notSupported);
+ }
+
+ createDirectory(): void {
+ throw new Error(notSupported);
+ }
+
+ rename(): void {
+ throw new Error(notSupported);
+ }
+}
diff --git a/src/commands/revisionDraft/discardRevisionDraft.ts b/src/commands/revisionDraft/discardRevisionDraft.ts
new file mode 100644
index 000000000..27e94f6a0
--- /dev/null
+++ b/src/commands/revisionDraft/discardRevisionDraft.ts
@@ -0,0 +1,35 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { KnownActiveRevisionsMode } from "@azure/arm-appcontainers";
+import { IActionContext } from "@microsoft/vscode-azext-utils";
+import { ext } from "../../extensionVariables";
+import { ContainerAppItem } from "../../tree/ContainerAppItem";
+import { RevisionDraftItem } from "../../tree/revisionManagement/RevisionDraftItem";
+import { localize } from "../../utils/localize";
+import { pickContainerApp } from "../../utils/pickContainerApp";
+
+export async function discardRevisionDraft(context: IActionContext, node?: ContainerAppItem | RevisionDraftItem): Promise {
+ const containerAppsItem = node ?? await pickContainerApp(context);
+ if (!ext.revisionDraftFileSystem.doesContainerAppsItemHaveRevisionDraft(containerAppsItem)) {
+ throw new Error(localize('noDraftExists', 'No draft changes exist for container app "{0}".', containerAppsItem.containerApp.name));
+ }
+
+ if (containerAppsItem.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) {
+ ext.revisionDraftFileSystem.discardRevisionDraft(containerAppsItem);
+ } else {
+ // Todo: Add this implementation back in with multiple revisions draft PR
+ // await ext.state.showDeleting(
+ // `${containerAppsItem.containerApp.id}/${RevisionDraftItem.idSuffix}`,
+ // async () => {
+ // // Add a short delay to display the deleting message
+ // await delay(5);
+ // ext.revisionDraftFileSystem.discardRevisionDraft(containerAppsItem);
+ // }
+ // );
+ }
+
+ ext.state.notifyChildrenChanged(containerAppsItem.containerApp.id);
+}
diff --git a/src/constants.ts b/src/constants.ts
index 8e942684a..29c10fee8 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -71,3 +71,9 @@ export type QuickPicksCache = { cache: QuickPickItem[], next: string | null };
// Originally from the Docker extension: https://github.com/microsoft/vscode-docker/blob/main/src/constants.ts
export const DOCKERFILE_GLOB_PATTERN = '**/{*.[dD][oO][cC][kK][eE][rR][fF][iI][lL][eE],[dD][oO][cC][kK][eE][rR][fF][iI][lL][eE],[dD][oO][cC][kK][eE][rR][fF][iI][lL][eE].*}';
+
+export const revisionModeSingleContextValue: string = 'revisionMode:single';
+export const revisionModeMultipleContextValue: string = 'revisionMode:multiple';
+
+// export const revisionDraftTrueContextValue: string = 'revisionDraft:true';
+// export const revisionDraftFalseContextValue: string = 'revisionDraft:false';
diff --git a/src/extension.ts b/src/extension.ts
index c97c97efd..693447709 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -10,6 +10,7 @@ import { callWithTelemetryAndErrorHandling, createAzExtOutputChannel, createExpe
import { AzExtResourceType, getAzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api';
import * as vscode from 'vscode';
import { registerCommands } from './commands/registerCommands';
+import { RevisionDraftFileSystem } from './commands/revisionDraft/RevisionDraftFileSystem';
import { ext } from './extensionVariables';
import { ContainerAppsBranchDataProvider } from './tree/ContainerAppsBranchDataProvider';
@@ -32,6 +33,9 @@ export async function activate(context: vscode.ExtensionContext, perfStats: { lo
registerCommands();
ext.experimentationService = await createExperimentationService(context);
+ ext.revisionDraftFileSystem = new RevisionDraftFileSystem();
+ context.subscriptions.push(vscode.workspace.registerFileSystemProvider(RevisionDraftFileSystem.scheme, ext.revisionDraftFileSystem));
+
ext.state = new TreeElementStateManager();
ext.rgApiV2 = await getAzureResourcesExtensionApi(context, '2.0.0');
ext.branchDataProvider = new ContainerAppsBranchDataProvider();
diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts
index 17df9255f..a922b1d51 100644
--- a/src/extensionVariables.ts
+++ b/src/extensionVariables.ts
@@ -6,6 +6,7 @@
import { IAzExtOutputChannel, IExperimentationServiceAdapter, TreeElementStateManager } from "@microsoft/vscode-azext-utils";
import { AzureResourcesExtensionApi } from "@microsoft/vscode-azureresources-api";
import { ExtensionContext } from "vscode";
+import { RevisionDraftFileSystem } from "./commands/revisionDraft/RevisionDraftFileSystem";
import { ContainerAppsBranchDataProvider } from "./tree/ContainerAppsBranchDataProvider";
/**
@@ -17,6 +18,7 @@ export namespace ext {
export let ignoreBundle: boolean | undefined;
export const prefix: string = 'containerApps';
export let experimentationService: IExperimentationServiceAdapter;
+ export let revisionDraftFileSystem: RevisionDraftFileSystem;
export let rgApiV2: AzureResourcesExtensionApi;
export let state: TreeElementStateManager;
diff --git a/src/tree/ContainerAppItem.ts b/src/tree/ContainerAppItem.ts
index f4401668d..f92f1bb60 100644
--- a/src/tree/ContainerAppItem.ts
+++ b/src/tree/ContainerAppItem.ts
@@ -7,9 +7,11 @@ import { ContainerApp, ContainerAppsAPIClient, KnownActiveRevisionsMode, Revisio
import { getResourceGroupFromId, uiUtils } from "@microsoft/vscode-azext-azureutils";
import { AzureWizard, DeleteConfirmationStep, IActionContext, callWithTelemetryAndErrorHandling, createContextValue, createSubscriptionContext, nonNullProp, nonNullValue } from "@microsoft/vscode-azext-utils";
import { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api";
+import * as deepEqual from "deep-eql";
import { TreeItem, TreeItemCollapsibleState, Uri } from "vscode";
import { DeleteAllContainerAppsStep } from "../commands/deleteContainerApp/DeleteAllContainerAppsStep";
import { IDeleteContainerAppWizardContext } from "../commands/deleteContainerApp/IDeleteContainerAppWizardContext";
+import { revisionModeMultipleContextValue, revisionModeSingleContextValue } from "../constants";
import { ext } from "../extensionVariables";
import { createActivityContext } from "../utils/activityUtils";
import { createContainerAppsAPIClient, createContainerAppsClient } from "../utils/azureClients";
@@ -19,9 +21,11 @@ import { treeUtils } from "../utils/treeUtils";
import type { ContainerAppsItem, TreeElementBase } from "./ContainerAppsBranchDataProvider";
import { LogsGroupItem } from "./LogsGroupItem";
import { ConfigurationItem } from "./configurations/ConfigurationItem";
-import { revisionModeMultipleContextValue, revisionModeSingleContextValue } from "./revisionManagement/RevisionItem";
+import { RevisionItem } from "./revisionManagement/RevisionItem";
import { RevisionsItem } from "./revisionManagement/RevisionsItem";
-import { ScaleItem } from "./scaling/ScaleItem";
+
+const unsavedChangesTrueContextValue: string = 'unsavedChanges:true';
+const unsavedChangesFalseContextValue: string = 'unsavedChanges:false';
export interface ContainerAppModel extends ContainerApp {
id: string;
@@ -60,9 +64,22 @@ export class ContainerAppItem implements ContainerAppsItem {
private get contextValue(): string {
const values: string[] = [ContainerAppItem.contextValue];
values.push(this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single ? revisionModeSingleContextValue : revisionModeMultipleContextValue);
+ values.push(this.hasUnsavedChanges() ? unsavedChangesTrueContextValue : unsavedChangesFalseContextValue);
return createContextValue(values);
}
+ private get description(): string | undefined {
+ if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single && this.hasUnsavedChanges()) {
+ return localize('unsavedChanges', 'Unsaved changes');
+ }
+
+ if (this.containerApp.provisioningState && this.containerApp.provisioningState !== 'Succeeded') {
+ return this.containerApp.provisioningState;
+ }
+
+ return undefined;
+ }
+
async getChildren(): Promise {
const result = await callWithTelemetryAndErrorHandling('getChildren', async (context) => {
const children: TreeElementBase[] = [];
@@ -70,7 +87,7 @@ export class ContainerAppItem implements ContainerAppsItem {
if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) {
const revision: Revision = await client.containerAppsRevisions.getRevision(this.resourceGroup, this.name, nonNullProp(this.containerApp, 'latestRevisionName'));
- children.push(new ScaleItem(this.subscription, this.containerApp, revision));
+ children.push(...RevisionItem.getTemplateChildren(this.subscription, this.containerApp, revision));
} else {
children.push(new RevisionsItem(this.subscription, this.containerApp));
}
@@ -89,7 +106,7 @@ export class ContainerAppItem implements ContainerAppsItem {
label: nonNullProp(this.containerApp, 'name'),
iconPath: treeUtils.getIconPath('azure-containerapps'),
contextValue: this.contextValue,
- description: this.containerApp.provisioningState === 'Succeeded' ? undefined : this.containerApp.provisioningState,
+ description: this.description,
collapsibleState: TreeItemCollapsibleState.Collapsed,
}
}
@@ -146,6 +163,15 @@ export class ContainerAppItem implements ContainerAppsItem {
});
ext.state.notifyChildrenChanged(this.containerApp.managedEnvironmentId);
}
+
+ private hasUnsavedChanges(): boolean {
+ const draftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this);
+ if (!this.containerApp.template || !draftTemplate) {
+ return false;
+ }
+
+ return !deepEqual(this.containerApp.template, draftTemplate);
+ }
}
export async function getContainerEnvelopeWithSecrets(context: IActionContext, subscription: AzureSubscription, containerApp: ContainerAppModel): Promise> {
diff --git a/src/tree/revisionManagement/RevisionDraftItem.ts b/src/tree/revisionManagement/RevisionDraftItem.ts
new file mode 100644
index 000000000..33056be0e
--- /dev/null
+++ b/src/tree/revisionManagement/RevisionDraftItem.ts
@@ -0,0 +1,65 @@
+/*---------------------------------------------------------------------------------------------
+* Copyright (c) Microsoft Corporation. All rights reserved.
+* Licensed under the MIT License. See License.txt in the project root for license information.
+*--------------------------------------------------------------------------------------------*/
+
+import { KnownActiveRevisionsMode, Revision } from "@azure/arm-appcontainers";
+import { TreeElementBase, nonNullProp } from "@microsoft/vscode-azext-utils";
+import type { AzureSubscription } from "@microsoft/vscode-azureresources-api";
+import { TreeItem, TreeItemCollapsibleState } from "vscode";
+import { ext } from "../../extensionVariables";
+import { localize } from "../../utils/localize";
+import { treeUtils } from "../../utils/treeUtils";
+import type { ContainerAppModel } from "../ContainerAppItem";
+import { RevisionItem, type RevisionsItemModel } from "./RevisionItem";
+
+export class RevisionDraftItem implements RevisionsItemModel {
+ static readonly idSuffix: string = 'revisionDraft';
+ static readonly contextValue: string = 'revisionDraftItem';
+ static readonly contextValueRegExp: RegExp = new RegExp(RevisionDraftItem.contextValue);
+
+ id: string;
+ revisionsMode: KnownActiveRevisionsMode;
+
+ constructor(readonly subscription: AzureSubscription, readonly containerApp: ContainerAppModel, readonly revision: Revision) {
+ this.id = `${this.containerApp.id}/${RevisionDraftItem.idSuffix}`;
+ this.revisionsMode = containerApp.revisionsMode;
+ }
+
+ private get revisionName(): string {
+ return nonNullProp(this.revision, 'name');
+ }
+
+ /**
+ * @example gets "rev-1" of "my-app--rev-1"
+ */
+ private get baseRevisionName(): string {
+ return this.revisionName.split('--').pop() ?? '';
+ }
+
+ static hasDescendant(item: RevisionsItemModel): boolean {
+ if (item instanceof RevisionDraftItem) {
+ return false;
+ }
+
+ const revisionDraftBaseName: string | undefined = ext.revisionDraftFileSystem.getRevisionDraftFile(item)?.baseRevisionName;
+ return item.revision.name === revisionDraftBaseName;
+ }
+
+ getTreeItem(): TreeItem {
+ return {
+ id: this.id,
+ label: localize('draft', 'Draft'),
+ iconPath: treeUtils.getIconPath('revision-draft'),
+ description: this.containerApp.latestRevisionName === this.revisionName ?
+ localize('basedOnLatestRevision', 'Based on "{0}" (Latest)', this.baseRevisionName) :
+ localize('basedOnRevision', 'Based on "{0}"', this.baseRevisionName),
+ contextValue: RevisionDraftItem.contextValue,
+ collapsibleState: TreeItemCollapsibleState.Expanded
+ };
+ }
+
+ getChildren(): TreeElementBase[] {
+ return RevisionItem.getTemplateChildren(this.subscription, this.containerApp, this.revision);
+ }
+}
diff --git a/src/tree/revisionManagement/RevisionItem.ts b/src/tree/revisionManagement/RevisionItem.ts
index 253a8783a..f0d598dc2 100644
--- a/src/tree/revisionManagement/RevisionItem.ts
+++ b/src/tree/revisionManagement/RevisionItem.ts
@@ -7,6 +7,7 @@ import { KnownActiveRevisionsMode, KnownRevisionProvisioningState, Revision } fr
import { TreeItemIconPath, createContextValue, nonNullProp } from "@microsoft/vscode-azext-utils";
import type { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api";
import { ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
+import { revisionModeMultipleContextValue, revisionModeSingleContextValue } from "../../constants";
import { localize } from "../../utils/localize";
import { treeUtils } from "../../utils/treeUtils";
import type { ContainerAppModel } from "../ContainerAppItem";
@@ -17,9 +18,6 @@ export interface RevisionsItemModel extends ContainerAppsItem {
revision: Revision;
}
-export const revisionModeSingleContextValue: string = 'revisionMode:single';
-export const revisionModeMultipleContextValue: string = 'revisionMode:multiple';
-
const revisionStateActiveContextValue: string = 'revisionState:active';
const revisionStateInactiveContextValue: string = 'revisionState:inactive';
@@ -42,7 +40,7 @@ export class RevisionItem implements RevisionsItemModel {
return createContextValue(values);
}
- get description(): string | undefined {
+ private get description(): string | undefined {
if (this.revisionsMode === KnownActiveRevisionsMode.Single) {
return undefined;
}
@@ -61,8 +59,14 @@ export class RevisionItem implements RevisionsItemModel {
label: nonNullProp(this.revision, 'name'),
};
- async getChildren(): Promise {
- return [new ScaleItem(this.subscription, this.containerApp, this.revision)];
+ static getTemplateChildren(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision): TreeElementBase[] {
+ return [
+ new ScaleItem(subscription, containerApp, revision)
+ ];
+ }
+
+ getChildren(): TreeElementBase[] {
+ return RevisionItem.getTemplateChildren(this.subscription, this.containerApp, this.revision);
}
getTreeItem(): TreeItem {
diff --git a/src/tree/scaling/ScaleItem.ts b/src/tree/scaling/ScaleItem.ts
index 00d443d63..47fab4471 100644
--- a/src/tree/scaling/ScaleItem.ts
+++ b/src/tree/scaling/ScaleItem.ts
@@ -3,27 +3,31 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { Revision, Scale } from "@azure/arm-appcontainers";
+import { KnownActiveRevisionsMode, Revision, Scale } from "@azure/arm-appcontainers";
import { createGenericElement, nonNullValue } from "@microsoft/vscode-azext-utils";
-import { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api";
+import type { AzureSubscription, ViewPropertiesModel } from "@microsoft/vscode-azureresources-api";
+import * as deepEqual from 'deep-eql';
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode";
+import { ext } from "../../extensionVariables";
import { localize } from "../../utils/localize";
import { treeUtils } from "../../utils/treeUtils";
-import { ContainerAppModel } from "../ContainerAppItem";
-import { ContainerAppsItem, TreeElementBase } from "../ContainerAppsBranchDataProvider";
+import type { ContainerAppModel } from "../ContainerAppItem";
+import type { TreeElementBase } from "../ContainerAppsBranchDataProvider";
+import type { RevisionsItemModel } from "../revisionManagement/RevisionItem";
import { createScaleRuleGroupItem } from "./ScaleRuleGroupItem";
const minMaxReplicaItemContextValue: string = 'minMaxReplicaItem';
-export class ScaleItem implements ContainerAppsItem {
+const scaling: string = localize('scaling', 'Scaling');
+
+export class ScaleItem implements RevisionsItemModel {
static readonly contextValue: string = 'scaleItem';
static readonly contextValueRegExp: RegExp = new RegExp(ScaleItem.contextValue);
constructor(
- public readonly subscription: AzureSubscription,
- public readonly containerApp: ContainerAppModel,
- public readonly revision: Revision,
- ) { }
+ readonly subscription: AzureSubscription,
+ readonly containerApp: ContainerAppModel,
+ readonly revision: Revision) { }
id: string = `${this.parentResource.id}/scale`;
@@ -43,22 +47,48 @@ export class ScaleItem implements ContainerAppsItem {
getTreeItem(): TreeItem {
return {
id: this.id,
- label: localize('scaling', 'Scaling'),
+ label: this.hasUnsavedChanges() ? `${scaling}*` : scaling,
contextValue: ScaleItem.contextValue,
iconPath: treeUtils.getIconPath('scaling'),
collapsibleState: TreeItemCollapsibleState.Collapsed,
}
}
- async getChildren?(): Promise {
+ getChildren(): TreeElementBase[] {
+ let scale: Scale | undefined;
+
+ if (this.hasUnsavedChanges()) {
+ scale = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale;
+ } else if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) {
+ scale = this.containerApp.template?.scale;
+ } else {
+ scale = this.revision.template?.scale;
+ }
+
return [
createGenericElement({
label: localize('minMax', 'Min / max replicas'),
- description: `${this.scale?.minReplicas ?? 0} / ${this.scale?.maxReplicas ?? 0}`,
+ description: `${scale?.minReplicas ?? 0} / ${scale?.maxReplicas ?? 0}`,
contextValue: minMaxReplicaItemContextValue,
iconPath: new ThemeIcon('dash'),
}),
- createScaleRuleGroupItem(this.subscription, this.containerApp, this.revision),
- ]
+ createScaleRuleGroupItem(this.subscription, this.containerApp, this.revision, scale?.rules ?? []),
+ ];
+ }
+
+ private hasUnsavedChanges(): boolean {
+ const scaleDraftTemplate = ext.revisionDraftFileSystem.parseRevisionDraft(this)?.scale;
+ if (!scaleDraftTemplate) {
+ return false;
+ }
+
+ if (this.containerApp.revisionsMode === KnownActiveRevisionsMode.Single) {
+ return !!this.containerApp.template?.scale && !deepEqual(this.containerApp.template.scale, scaleDraftTemplate);
+ } else {
+ // We only care about showing changes to descendants of the revision draft item when in multiple revisions mode
+ // return !!this.revision.template?.scale && RevisionDraftItem.hasDescendant(this) && !deepEqual(this.revision.template.scale, scaleDraftTemplate);
+
+ return false; // Placeholder
+ }
}
}
diff --git a/src/tree/scaling/ScaleRuleGroupItem.ts b/src/tree/scaling/ScaleRuleGroupItem.ts
index b0b178639..6696c2918 100644
--- a/src/tree/scaling/ScaleRuleGroupItem.ts
+++ b/src/tree/scaling/ScaleRuleGroupItem.ts
@@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import { Revision, ScaleRule } from "@azure/arm-appcontainers";
-import { nonNullValueAndProp } from "@microsoft/vscode-azext-utils";
import { AzureSubscription } from "@microsoft/vscode-azureresources-api";
import { ThemeIcon, TreeItemCollapsibleState } from "vscode";
import { localize } from "../../utils/localize";
@@ -18,8 +17,7 @@ export interface ScaleRuleGroupItem extends RevisionsItemModel {
const scaleRuleGroupItemContextValue: string = 'scaleRuleGroupItem';
-export function createScaleRuleGroupItem(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision): ScaleRuleGroupItem {
- const scaleRules = nonNullValueAndProp(revision.template, 'scale').rules ?? [];
+export function createScaleRuleGroupItem(subscription: AzureSubscription, containerApp: ContainerAppModel, revision: Revision, scaleRules: ScaleRule[]): ScaleRuleGroupItem {
const parentResource = revision.name === containerApp.latestRevisionName ? containerApp : revision;
const id = `${parentResource.id}/scalerules`;
diff --git a/src/utils/delay.ts b/src/utils/delay.ts
new file mode 100644
index 000000000..73787ad16
--- /dev/null
+++ b/src/utils/delay.ts
@@ -0,0 +1,8 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.md in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+export async function delay(ms: number): Promise {
+ await new Promise((resolve: () => void): NodeJS.Timer => setTimeout(resolve, ms));
+}