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)); +}